1use tinyvec::{ArrayVec, array_vec};
9
10use core::ops::Range;
11use std::{
12 borrow::Cow,
13 fmt::{self, Debug},
14};
15
16trait ParsedRange {
17 fn range(&self) -> &Range<usize>;
18}
19
20impl ParsedRange for Range<usize> {
21 fn range(&self) -> &Range<usize> {
22 self
23 }
24}
25
26#[allow(dead_code)]
27#[derive(Debug, Clone)]
28pub struct FileDiff {
29 pub range: Range<usize>,
30 pub header: DiffHeader,
31 pub hunks: Vec<Hunk>,
32}
33
34#[allow(dead_code)]
35#[derive(Debug, PartialEq, Eq, Clone, Copy)]
36pub enum Status {
37 Added,
38 Deleted,
39 Modified,
40 Renamed,
41 Copied,
42 Unmerged,
43}
44
45#[allow(dead_code)]
46#[derive(Debug, Clone)]
47pub struct DiffHeader {
48 pub range: Range<usize>,
49 pub old_file: FilePath,
50 pub new_file: FilePath,
51 pub status: Status,
52}
53
54impl FilePath {
55 pub fn fmt<'a>(&'a self, input: &'a str) -> Cow<'a, str> {
56 if self.is_quoted {
57 Cow::Owned(
58 String::from_utf8(
59 smashquote::unescape_bytes(input[self.range.clone()].as_bytes()).unwrap(),
60 )
61 .unwrap(),
62 )
63 } else {
64 Cow::Borrowed(&input[self.range.clone()])
65 }
66 }
67}
68
69#[derive(Debug, Clone)]
70pub struct FilePath {
71 pub range: Range<usize>,
72 pub is_quoted: bool,
73}
74
75#[allow(dead_code)]
76#[derive(Debug, Clone)]
77pub struct Hunk {
78 pub range: Range<usize>,
79 pub header: HunkHeader,
80 pub content: HunkContent,
81}
82
83#[allow(dead_code)]
84#[derive(Debug, Clone)]
85pub struct HunkContent {
86 pub range: Range<usize>,
87 pub changes: Vec<Change>,
88}
89
90#[allow(dead_code)]
91#[derive(Debug, Clone)]
92pub struct HunkHeader {
93 pub range: Range<usize>,
94 pub old_line_start: u32,
95 pub old_line_count: u32,
96 pub new_line_start: u32,
97 pub new_line_count: u32,
98 pub fn_ctx: Range<usize>,
99}
100
101#[allow(dead_code)]
102#[derive(Debug, Clone)]
103pub struct Change {
104 pub old: Range<usize>,
105 pub new: Range<usize>,
106}
107
108pub type Result<'a, T> = std::result::Result<T, ParseError<'a>>;
109
110pub struct ParseError<'a> {
111 errors: ArrayVec<[ThinParseError; 4]>,
112 parser: Parser<'a>,
113}
114
115type ThinResult<T> = std::result::Result<T, ArrayVec<[ThinParseError; 4]>>;
119
120#[derive(Default)]
121pub struct ThinParseError {
122 expected: &'static str,
123}
124
125impl fmt::Display for ParseError<'_> {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 f.write_str("Error parsing diff\n")?;
128 for (i, error) in self.errors.iter().enumerate() {
129 if i == 0 {
130 f.write_fmt(format_args!("expected {:?}", error.expected))?;
131 } else {
132 f.write_fmt(format_args!("within {:?}", error.expected))?;
133 }
134
135 f.write_str("\n")?;
136 }
137
138 self.parser.fmt(f)?;
139
140 Ok(())
141 }
142}
143
144impl fmt::Debug for ParseError<'_> {
145 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
146 f.write_fmt(format_args!("{self}"))
147 }
148}
149
150impl fmt::Display for ThinParseError {
151 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
152 write!(f, "Expected {}", self.expected)
153 }
154}
155
156impl fmt::Debug for ThinParseError {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.write_fmt(format_args!("{self}"))
159 }
160}
161
162impl std::error::Error for ThinParseError {}
163
164#[derive(Clone)]
165pub struct Parser<'a> {
166 input: &'a str,
167 cursor: usize,
168}
169
170type ParseFn<'a, T> = fn(&mut Parser<'a>) -> ThinResult<T>;
171
172impl<'a> fmt::Debug for Parser<'a> {
173 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
174 let cursor = self.cursor;
175 let line_start = self.input[..cursor].rfind('\n').unwrap_or(0);
177 let line_end = self.input[line_start..]
178 .find('\n')
179 .unwrap_or(self.input.len());
180 let line = &self.input[line_start..line_end];
181 f.write_fmt(format_args!("{}\n", line))?;
182 for _ in line_start..cursor {
183 f.write_str(" ")?;
184 }
185 f.write_str("^")?;
186 Ok(())
187 }
188}
189
190impl<'a> Parser<'a> {
191 pub fn new(input: &'a str) -> Self {
192 Self { input, cursor: 0 }
193 }
194
195 pub fn parse_diff<'b>(&'b mut self) -> Result<'b, Vec<FileDiff>> {
199 self.parse(false)
200 }
201
202 pub fn parse_patches<'b>(&'b mut self) -> Result<'b, Vec<FileDiff>> {
205 self.parse(true)
206 }
207
208 fn parse<'b>(&'b mut self, between_diffs: bool) -> Result<'b, Vec<FileDiff>> {
209 log::trace!("Parser::parse\n{:?}", self);
210 let mut diffs = vec![];
211
212 if self.input.is_empty() {
213 return Ok(vec![]);
214 }
215
216 if !diffs.is_empty() {
217 return Ok(diffs);
218 }
219
220 self.skip_until_diff_header().map_err(|errors| ParseError {
221 errors,
222 parser: self.clone(),
223 })?;
224
225 while self.is_at_diff_header() {
226 diffs.push(self.file_diff().map_err(|errors| ParseError {
227 errors,
228 parser: self.clone(),
229 })?);
230 if between_diffs {
231 self.skip_until_diff_header().map_err(|errors| ParseError {
232 errors,
233 parser: self.clone(),
234 })?;
235 }
236 }
237
238 self.eof().map_err(|errors| ParseError {
239 errors,
240 parser: self.clone(),
241 })?;
242
243 Ok(diffs)
244 }
245
246 fn skip_until_diff_header(&mut self) -> ThinResult<()> {
247 log::trace!("Parser::skip_until_diff_header\n{:?}", self);
248 while self.cursor < self.input.len() && !self.is_at_diff_header() {
249 self.consume_until_after(Self::newline_or_eof)?;
250 }
251
252 Ok(())
253 }
254
255 fn is_at_diff_header(&mut self) -> bool {
256 self.peek("diff") || self.peek("*")
257 }
258
259 fn file_diff(&mut self) -> ThinResult<FileDiff> {
260 log::trace!("Parser::file_diff\n{:?}", self);
261 let diff_start = self.cursor;
262 let header = self.diff_header().map_err(|mut err| {
263 err.try_push(ThinParseError {
264 expected: "<diff header>",
265 });
266 err
267 })?;
268
269 let mut hunks = vec![];
270
271 if header.status == Status::Unmerged {
272 self.skip_until_diff_header()?;
273 } else {
274 while self.peek("@@") {
275 hunks.push(self.hunk().map_err(|mut err| {
276 err.try_push(ThinParseError { expected: "<hunk>" });
277 err
278 })?);
279 }
280 }
281
282 Ok(FileDiff {
283 range: diff_start..self.cursor,
284 header,
285 hunks,
286 })
287 }
288
289 fn diff_header(&mut self) -> ThinResult<DiffHeader> {
290 log::trace!("Parser::diff_header\n{:?}", self);
291 let diff_header_start = self.cursor;
292 let mut diff_type = Status::Modified;
293
294 if let Ok(unmerged) = self.unmerged_file() {
295 return Ok(unmerged);
296 }
297
298 let (mut old_file, mut new_file, is_conflicted) = self
299 .conflicted_file()
300 .or_else(|_| self.old_new_file_header())?;
301
302 if is_conflicted {
303 diff_type = Status::Unmerged;
304 }
305
306 if self.peek("new file") {
307 diff_type = Status::Added;
308 self.consume_until_after(Self::newline)?;
309 } else if self.peek("deleted file") {
310 diff_type = Status::Deleted;
311 self.consume_until_after(Self::newline)?;
312 }
313
314 if self.consume("similarity index").is_ok() {
315 self.consume_until_after(Self::newline)?;
316 }
317
318 if self.consume("dissimilarity index").is_ok() {
319 self.consume_until_after(Self::newline)?;
320 }
321
322 if self.peek("index") {
323 } else if self.peek("old mode") {
324 self.consume_until_after(Self::newline)?;
325 if self.peek("new mode") {
326 self.consume_until_after(Self::newline)?;
327 }
328 } else if self.peek("new mode") {
329 self.consume_until_after(Self::newline)?;
330 } else if self.peek("deleted file mode") {
331 diff_type = Status::Deleted;
332 self.consume_until_after(Self::newline)?;
333 } else if self.peek("new file mode") {
334 diff_type = Status::Added;
335 self.consume_until_after(Self::newline)?;
336 } else if self.peek("copy from") {
337 diff_type = Status::Copied;
338 self.consume_until_after(Self::newline)?;
339 self.consume("copy to")?;
340 self.consume_until_after(Self::newline)?;
341 } else if self.peek("rename from") {
342 diff_type = Status::Renamed;
343 self.consume_until_after(Self::newline)?;
344 self.consume("rename to")?;
345 self.consume_until_after(Self::newline)?;
346 }
347
348 if self.peek("index") {
349 self.consume("index ")?;
350 self.consume_until_after(Self::newline_or_eof)?;
351 }
352
353 if self.peek("Binary files ") {
354 self.consume_until_after(Self::newline_or_eof)?;
355 }
356
357 if self.consume("--- ").is_ok() {
358 old_file = self.diff_header_path(Self::newline_or_eof)?;
359 self.newline_or_eof()?;
360 self.consume("+++ ")?;
361 new_file = self.diff_header_path(Self::newline_or_eof)?;
362 self.newline_or_eof()?;
363 }
364
365 Ok(DiffHeader {
366 range: diff_header_start..self.cursor,
367 old_file,
368 new_file,
369 status: diff_type,
370 })
371 }
372
373 fn unmerged_file(&mut self) -> ThinResult<DiffHeader> {
374 log::trace!("Parser::unmerged_file\n{:?}", self);
375 let unmerged_path_prefix = self.consume("* Unmerged path ")?;
376 let file = self.diff_header_path(Self::newline_or_eof)?;
377 self.newline_or_eof()?;
378
379 Ok(DiffHeader {
380 range: unmerged_path_prefix.start..self.cursor,
381 old_file: file.clone(),
382 new_file: file,
383 status: Status::Unmerged,
384 })
385 }
386
387 fn old_new_file_header(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
388 log::trace!("Parser::old_new_file_header\n{:?}", self);
389 self.consume("diff --git ")?;
390
391 let delim: ParseFn<Range<usize>> = if self.peek("\"") {
397 Self::ascii_whitespace
398 } else if self.peek_fn(Self::diff_header_path_prefix) {
399 |p: &mut Parser<'_>| {
400 let start = p.cursor;
401 p.ascii_whitespace()
402 .and_then(|_| p.diff_header_path_prefix())?;
403 Ok(start..p.cursor)
404 }
405 } else {
406 Self::ascii_whitespace
407 };
408
409 let old_path = self.diff_header_path(delim)?;
410 self.ascii_whitespace()?;
411 let new_path = self.diff_header_path(Self::newline_or_eof)?;
412 self.newline_or_eof()?;
413
414 Ok((old_path, new_path, false))
415 }
416
417 fn diff_header_path(
419 &mut self,
420 stop_lookahead: ParseFn<'a, Range<usize>>,
421 ) -> ThinResult<FilePath> {
422 log::trace!("Parser::diff_header_path\n{:?}", self);
423 if self.consume("\"").ok().is_some() {
424 self.diff_header_path_prefix().ok();
425 let quoted = self.quoted()?;
426 Ok(FilePath {
427 range: quoted,
428 is_quoted: true,
429 })
430 } else {
431 self.diff_header_path_prefix().ok();
432 let consumed = self.consume_until_before(stop_lookahead)?;
433 Ok(FilePath {
434 range: consumed,
435 is_quoted: false,
436 })
437 }
438 }
439
440 fn quoted(&mut self) -> ThinResult<Range<usize>> {
441 log::trace!("Parser::quoted\n{:?}", self);
442 let start = self.cursor;
443
444 while !self.peek("\"") {
445 if self.peek("\\") {
446 self.escaped()?;
447 } else {
448 self.cursor += 1
449 }
450 }
451
452 let range = start..self.cursor;
453 self.consume("\"")?;
454 Ok(range)
455 }
456
457 fn escaped(&mut self) -> ThinResult<Range<usize>> {
458 log::trace!("Parser::escaped\n{:?}", self);
459 let start = self.cursor;
460
461 self.consume("\\")?;
462 match self.input.get(self.cursor..self.cursor + 1) {
463 Some("a" | "b" | "e" | "E" | "f" | "n" | "r" | "t" | "v" | "\'" | "\"" | "\\") => {
464 self.cursor += 1;
465 }
466 Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9") => {
467 self.cursor += 1;
468 for _ in 0..2 {
469 if !matches!(
470 self.input.get(self.cursor..self.cursor + 1),
471 Some("0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9")
472 ) {
473 break;
474 }
475
476 self.cursor += 1;
477 }
478 }
479 _ => {
480 self.cursor = start;
481 return Err(array_vec![ThinParseError {
482 expected: "<escaped char>",
483 }]);
484 }
485 }
486
487 Ok(start..self.cursor)
488 }
489
490 fn diff_header_path_prefix(&mut self) -> ThinResult<Range<usize>> {
491 log::trace!("Parser::diff_header_path_prefix\n{:?}", self);
492 let start = self.cursor;
493 self.ascii_lowercase()
494 .and_then(|_| self.consume("/"))
495 .map_err(|_| {
496 self.cursor = start;
497 array_vec![ThinParseError {
498 expected: "<diff header path prefix (e.g. 'a/' or 'b/')>",
499 }]
500 })?;
501
502 Ok(start..self.cursor)
503 }
504
505 fn ascii_lowercase(&mut self) -> ThinResult<Range<usize>> {
506 log::trace!("Parser::ascii_lowercase\n{:?}", self);
507 let start = self.cursor;
508 let is_ascii_lowercase = self
509 .input
510 .get(self.cursor..)
511 .and_then(|s| s.chars().next())
512 .is_some_and(|c| c.is_ascii_lowercase());
513
514 if is_ascii_lowercase {
515 self.cursor += 1;
516 Ok(start..self.cursor)
517 } else {
518 Err(array_vec![ThinParseError {
519 expected: "<ascii lowercase char>",
520 }])
521 }
522 }
523
524 fn conflicted_file(&mut self) -> ThinResult<(FilePath, FilePath, bool)> {
525 log::trace!("Parser::conflicted_file\n{:?}", self);
526 self.consume("diff --cc ")?;
527 let file = self.diff_header_path(Self::newline_or_eof)?;
528 self.newline_or_eof()?;
529 Ok((file.clone(), file, true))
530 }
531
532 fn hunk(&mut self) -> ThinResult<Hunk> {
533 log::trace!("Parser::hunk\n{:?}", self);
534 let hunk_start = self.cursor;
535 let header = self.hunk_header().map_err(|mut err| {
536 err.try_push(ThinParseError {
537 expected: "<hunk header>",
538 });
539 err
540 })?;
541 let content = self.hunk_content().map_err(|mut err| {
542 err.try_push(ThinParseError {
543 expected: "<hunk content>",
544 });
545 err
546 })?;
547
548 Ok(Hunk {
549 range: hunk_start..self.cursor,
550 header,
551 content,
552 })
553 }
554
555 fn hunk_content(&mut self) -> ThinResult<HunkContent> {
556 log::trace!("Parser::hunk_content\n{:?}", self);
557 let hunk_content_start = self.cursor;
558 let mut changes = vec![];
559
560 while self.cursor < self.input.len()
561 && [" ", "-", "+", "\\"]
562 .into_iter()
563 .any(|prefix| self.peek(prefix))
564 {
565 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
566 changes.push(self.change()?);
567 self.consume_lines_while_prefixed(|parser| parser.peek(" ") || parser.peek("\\"))?;
568 }
569
570 Ok(HunkContent {
571 range: hunk_content_start..self.cursor,
572 changes,
573 })
574 }
575
576 fn hunk_header(&mut self) -> ThinResult<HunkHeader> {
577 log::trace!("Parser::hunk_header\n{:?}", self);
578 let hunk_header_start = self.cursor;
579
580 self.consume("@@ -")?;
581 let old_line_start = self.number()?;
582 let old_line_count = if self.consume(",").is_ok() {
583 self.number()?
584 } else {
585 1
586 };
587 self.consume(" +")?;
588 let new_line_start = self.number()?;
589 let new_line_count = if self.consume(",").is_ok() {
590 self.number()?
591 } else {
592 1
593 };
594 self.consume(" @@")?;
595 self.consume(" ").ok();
596
597 let (fn_ctx, newline) = self.consume_until_after(Self::newline_or_eof)?;
598
599 Ok(HunkHeader {
600 range: hunk_header_start..self.cursor,
601 old_line_start,
602 old_line_count,
603 new_line_start,
604 new_line_count,
605 fn_ctx: fn_ctx.start..newline.end,
606 })
607 }
608
609 fn change(&mut self) -> ThinResult<Change> {
610 log::trace!("Parser::change\n{:?}", self);
611 let removed = self.consume_lines_while_prefixed(|parser| parser.peek("-"))?;
612 let removed_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
613 let added = self.consume_lines_while_prefixed(|parser| parser.peek("+"))?;
614 let added_meta = self.consume_lines_while_prefixed(|parser| parser.peek("\\"))?;
615
616 Ok(Change {
617 old: removed.start..removed_meta.end,
618 new: added.start..added_meta.end,
619 })
620 }
621
622 fn consume_lines_while_prefixed(
623 &mut self,
624 pred: fn(&Parser) -> bool,
625 ) -> ThinResult<Range<usize>> {
626 log::trace!("Parser::consume_lines_while_prefixed\n{:?}", self);
627 let start = self.cursor;
628 while self.cursor < self.input.len() && pred(self) {
629 self.consume_until_after(Self::newline_or_eof)?;
630 }
631
632 Ok(start..self.cursor)
633 }
634
635 fn number(&mut self) -> ThinResult<u32> {
636 log::trace!("Parser::number\n{:?}", self);
637 let digit_count = &self
638 .input
639 .get(self.cursor..)
640 .map(|s| s.chars().take_while(|c| c.is_ascii_digit()).count())
641 .unwrap_or(0);
642
643 if digit_count == &0 {
644 return Err(array_vec![ThinParseError {
645 expected: "<number>",
646 }]);
647 }
648
649 self.cursor += digit_count;
650 Ok(self
651 .input
652 .get(self.cursor - digit_count..self.cursor)
653 .ok_or(array_vec![ThinParseError {
654 expected: "<number>",
655 }])?
656 .parse()
657 .unwrap())
658 }
659
660 fn newline_or_eof(&mut self) -> ThinResult<Range<usize>> {
661 log::trace!("Parser::newline_or_eof\n{:?}", self);
662 self.newline().or_else(|_| self.eof()).map_err(|_| {
663 array_vec![ThinParseError {
664 expected: "<newline or eof>",
665 }]
666 })
667 }
668
669 fn newline(&mut self) -> ThinResult<Range<usize>> {
670 log::trace!("Parser::newline\n{:?}", self);
671 self.consume("\r\n")
672 .or_else(|_| self.consume("\n"))
673 .map_err(|_| {
674 array_vec![ThinParseError {
675 expected: "<newline>",
676 }]
677 })
678 }
679
680 fn ascii_whitespace(&mut self) -> ThinResult<Range<usize>> {
681 log::trace!("Parser::ascii_whitespace\n{:?}", self);
682 self.consume(" ")
683 .or_else(|_| self.consume("\t"))
684 .or_else(|_| self.consume("\n"))
685 .or_else(|_| self.consume("\r\n"))
686 .or_else(|_| self.consume("\r"))
687 .or_else(|_| self.consume("\x0C"))
688 .map_err(|_| {
689 array_vec![ThinParseError {
690 expected: "<ascii whitespace>",
691 }]
692 })
693 }
694
695 fn eof(&mut self) -> ThinResult<Range<usize>> {
696 log::trace!("Parser::eof\n{:?}", self);
697 if self.cursor == self.input.len() {
698 Ok(self.cursor..self.cursor)
699 } else {
700 Err(array_vec![ThinParseError { expected: "<eof>" }])
701 }
702 }
703
704 fn consume_until_before<T: ParsedRange>(
709 &mut self,
710 parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
711 ) -> ThinResult<Range<usize>> {
712 log::trace!("Parser::consume_until_before\n{:?}", self);
713 let start = self.cursor;
714 let found = self.find(parse_fn).map_err(|mut err| {
715 err.try_push(ThinParseError {
716 expected: "to consume the match",
717 });
718 err
719 })?;
720 self.cursor = found.range().start;
721 Ok(start..found.range().start)
722 }
723
724 fn consume_until_after<T: ParsedRange>(
729 &mut self,
730 parse_fn: fn(&mut Parser<'a>) -> ThinResult<T>,
731 ) -> ThinResult<(Range<usize>, T)> {
732 log::trace!("Parser::consume_until_after\n{:?}", self);
733 let start = self.cursor;
734 let found = self.find(parse_fn).map_err(|mut err| {
735 err.try_push(ThinParseError {
736 expected: "to consume the match",
737 });
738 err
739 })?;
740 self.cursor = found.range().end;
741 Ok((start..found.range().start, found))
742 }
743
744 fn find<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> ThinResult<T> {
748 log::trace!("Parser::find\n{:?}", self);
749 let mut sub_parser = self.clone();
750 let mut error = None;
751
752 for pos in self.cursor..=self.input.len() {
753 sub_parser.cursor = pos;
754 match parse_fn(&mut sub_parser) {
755 Ok(result) => return Ok(result),
756 Err(err) => {
757 if error.is_none() {
758 error = Some(err);
759 }
760 continue;
761 }
762 }
763 }
764
765 let mut errors = error.unwrap();
766 errors.try_push(ThinParseError {
767 expected: "to find a match",
768 });
769
770 Err(errors)
771 }
772
773 fn peek_fn<T: ParsedRange>(&self, parse_fn: ParseFn<'a, T>) -> bool {
774 let mut p = Parser {
775 input: self.input,
776 cursor: self.cursor,
777 };
778 parse_fn(&mut p).is_ok()
779 }
780
781 fn consume(&mut self, expected: &'static str) -> ThinResult<Range<usize>> {
784 let start = self.cursor;
785
786 if !self.peek(expected) {
787 return Err(array_vec![ThinParseError { expected }]);
788 }
789
790 self.cursor += expected.len();
791 Ok(start..self.cursor)
792 }
793
794 fn peek(&self, pattern: &str) -> bool {
796 self.input
797 .get(self.cursor..)
798 .is_some_and(|s| s.starts_with(pattern))
799 }
800}
801
802#[cfg(test)]
803mod tests {
804 use super::*;
805
806 #[test]
807 fn parse_empty_input() {
808 let mut parser = Parser::new("");
809 let diffs = parser.parse_diff().unwrap();
810 assert!(diffs.is_empty(), "Expected empty vector for empty input");
811 }
812
813 #[test]
814 fn the_file_header_range_ends_where_its_first_hunk_begins() {
815 let input = "diff --git a/file1.txt b/file2.txt\n\
816 index 0000000..1111111 100644\n\
817 --- a/file1.txt\n\
818 +++ b/file2.txt\n\
819 @@ -1,2 +1,2 @@\n\
820 -foo\n\
821 +bar\n";
822 let diff = Parser::new(input).parse_diff().unwrap();
823 assert_eq!(diff[0].header.range, 0..97);
824 }
825
826 #[test]
827 fn parse_valid_diff() {
828 let input = "diff --git a/file1.txt b/file2.txt\n\
829 index 0000000..1111111 100644\n\
830 --- a/file1.txt\n\
831 +++ b/file2.txt\n\
832 @@ -1,2 +1,2 @@ fn main() {\n\
833 -foo\n\
834 +bar\n";
835 let mut parser = Parser::new(input);
836 let diffs = parser.parse_diff().unwrap();
837 assert_eq!(diffs.len(), 1, "Expected one diff block");
838
839 let diff = &diffs[0];
840 let old_file_str = diff.header.old_file.fmt(input);
841 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
842
843 let new_file_str = diff.header.new_file.fmt(input);
844 assert_eq!(new_file_str, "file2.txt", "New file does not match");
845
846 assert_eq!(diff.hunks.len(), 1, "Expected one hunk");
847 let hunk = &diff.hunks[0];
848
849 assert_eq!(hunk.header.old_line_start, 1, "Old line start should be 1");
850 assert_eq!(hunk.header.old_line_count, 2, "Old line count should be 2");
851 assert_eq!(hunk.header.new_line_start, 1, "New line start should be 1");
852 assert_eq!(hunk.header.new_line_count, 2, "New line count should be 2");
853
854 let func_ctx = &input[hunk.header.fn_ctx.clone()];
855 assert_eq!(func_ctx, "fn main() {\n", "Expected function context");
856
857 assert_eq!(
858 hunk.content.changes.len(),
859 1,
860 "Expected one change in the hunk"
861 );
862 let change = &hunk.content.changes[0];
863 let removed_str = &input[change.old.clone()];
864 assert_eq!(removed_str, "-foo\n", "Removed line does not match");
865 let added_str = &input[change.new.clone()];
866 assert_eq!(added_str, "+bar\n", "Added line does not match");
867 }
868
869 #[test]
870 fn parse_multiple_diffs() {
871 let input = "diff --git a/file1.txt b/file1.txt\n\
872 index 0000000..1111111 100644\n\
873 --- a/file1.txt\n\
874 +++ b/file1.txt\n\
875 @@ -1,1 +1,1 @@\n\
876 -foo\n\
877 +bar\n\
878 diff --git a/file2.txt b/file2.txt\n\
879 index 2222222..3333333 100644\n\
880 --- a/file2.txt\n\
881 +++ b/file2.txt\n\
882 @@ -2,2 +2,2 @@\n\
883 -baz\n\
884 +qux\n";
885 let mut parser = Parser::new(input);
886 let diffs = parser.parse_diff().unwrap();
887 assert_eq!(diffs.len(), 2, "Expected two diff blocks");
888
889 let diff1 = &diffs[0];
890 let old_file1 = &input[diff1.header.old_file.range.clone()];
891 assert_eq!(old_file1, "file1.txt", "First diff old file mismatch");
892
893 let diff2 = &diffs[1];
894 let old_file2 = &input[diff2.header.old_file.range.clone()];
895 assert_eq!(old_file2, "file2.txt", "Second diff old file mismatch");
896 }
897
898 #[test]
899 fn parse_crlf_input() {
900 let input = "diff --git a/file.txt b/file.txt\r\n\
901 index 0000000..1111111 100644\r\n\
902 --- a/file.txt\r\n\
903 +++ b/file.txt\r\n\
904 @@ -1,1 +1,1 @@\r\n\
905 -foo\r\n\
906 +bar\r\n";
907 let mut parser = Parser::new(input);
908 let diffs = parser.parse_diff().unwrap();
909 assert_eq!(diffs.len(), 1, "Expected one diff block for CRLF input");
910 let diff = &diffs[0];
911 let old_file = &input[diff.header.old_file.range.clone()];
912 assert_eq!(
913 old_file, "file.txt",
914 "Old file does not match in CRLF input"
915 );
916 }
917
918 #[test]
919 fn parse_malformed_input_missing_diff_header() {
920 let input = "--- a/file.txt\n+++ b/file.txt\n";
921 let mut parser = Parser::new(input);
922 assert_eq!(parser.parse_diff().unwrap().len(), 0);
923 }
924
925 #[test]
926 fn parse_malformed_input_missing_hunk_header() {
927 let input = "diff --git a/file.txt b/file.txt\n\
928 index 0000000..1111111 100644\n\
929 --- a/file.txt\n\
930 +++ b/file.txt\n\
931 foo\n";
932 let mut parser = Parser::new(input);
933 assert!(parser.parse_diff().is_err());
934 }
935
936 #[test]
937 fn parse_malformed_input_invalid_number() {
938 let input = "diff --git a/file.txt b/file.txt\n\
939 index 0000000..1111111 100644\n\
940 --- a/file.txt\n\
941 +++ b/file.txt\n\
942 @@ -a,1 +1,1 @@\n\
943 -foo\n\
944 +bar\n";
945 let mut parser = Parser::new(input);
946 assert!(parser.parse_diff().is_err());
947 }
948
949 #[test]
950 fn parse_malformed_input_extra_characters() {
951 let input = "diff --git a/file.txt b/file.txt\n\
952 index 0000000..1111111 100644\n\
953 --- a/file.txt\n\
954 +++ b/file.txt\n\
955 @@ -1,1 +1,1 @@\n\
956 -foo\n\
957 +bar\n\
958 unexpected\n";
959 let mut parser = Parser::new(input);
960 assert!(parser.parse_diff().is_err());
961 }
962
963 #[test]
964 fn unified_diff_break() {
965 let input = "diff --git a/file.txt b/file.txt\r\n\
966 index 0000000..1111111 100644\r\n\
967 --- a/file.txt\r\n\
968 +++ b/file.txt\r\n\
969 @@ -1,1 +1,1 @@\r\n\
970 -foo\r\n\
971 +bar\r\n";
972 let mut parser = Parser::new(input);
973 let _ = parser.parse_diff();
974 }
975
976 #[test]
977 fn new_file() {
978 let input = "diff --git a/file.txt b/file.txt\r\n\
979 new file mode 100644\r\n\
980 index 0000000..1111111\r\n\
981 --- /dev/null\r\n\
982 +++ b/file.txt\r\n\
983 @@ -0,0 +1,1 @@\r\n\
984 +bar\r\n";
985 let mut parser = Parser::new(input);
986 let diffs = parser.parse_diff().unwrap();
987 assert_eq!(diffs.len(), 1, "Expected one diff block for new file test");
988 let diff = &diffs[0];
989 let old_file_str = diff.header.old_file.fmt(input);
990 assert_eq!(old_file_str, "/dev/null",);
991 let new_file_str = diff.header.new_file.fmt(input);
992 assert_eq!(new_file_str, "file.txt",);
993 }
994
995 #[test]
996 fn omitted_line_count() {
997 let input = "diff --git a/file.txt b/file.txt\r\n\
998 index 0000000..1111111 100644\r\n\
999 --- a/file.txt\r\n\
1000 +++ b/file.txt\r\n\
1001 @@ -1 +1 @@\r\n\
1002 -foo\r\n\
1003 +bar\r\n";
1004 let mut parser = Parser::new(input);
1005 let diffs = parser.parse_diff().unwrap();
1006 assert_eq!(
1007 diffs.len(),
1008 1,
1009 "Expected one diff block for omitted line count test"
1010 );
1011 let diff = &diffs[0];
1012 let hunk = &diff.hunks[0];
1013 assert_eq!(hunk.header.old_line_count, 1, "Old line count should be 1");
1014 assert_eq!(hunk.header.new_line_count, 1, "New line count should be 1");
1015 }
1016
1017 #[test]
1018 fn new_empty_files() {
1019 let input = "diff --git a/file-a b/file-a\n\
1020 new file mode 100644\n\
1021 index 0000000..e69de29\n\
1022 diff --git a/file-b b/file-b\n\
1023 new file mode 100644\n\
1024 index 0000000..e69de29\n";
1025 let mut parser = Parser::new(input);
1026 let diffs = parser.parse_diff().unwrap();
1027 assert_eq!(diffs.len(), 2, "Expected two diff blocks for new files");
1028 assert_eq!(diffs[0].header.status, Status::Added);
1029 assert_eq!(diffs[0].hunks.len(), 0, "Expected no hunks in first diff");
1030 assert_eq!(diffs[1].header.status, Status::Added);
1031 assert_eq!(diffs[1].hunks.len(), 0, "Expected no hunks in second diff");
1032 }
1033
1034 #[test]
1035 fn deleted_file() {
1036 let input = "diff --git a/Cargo.lock b/Cargo.lock\n\
1037 deleted file mode 100644\n\
1038 index 6ae58a0..0000000\n\
1039 --- a/Cargo.lock\n\
1040 +++ /dev/null\n";
1041 let mut parser = Parser::new(input);
1042 let diffs = parser.parse_diff().unwrap();
1043 assert_eq!(diffs.len(), 1, "Expected two diff blocks for new files");
1044 assert_eq!(diffs[0].header.status, Status::Deleted);
1045 }
1046
1047 #[test]
1048 fn mode_change() {
1049 let input = "diff --git a/test-file b/test-file\n\
1050 old mode 100644\n\
1051 new mode 100755\n\
1052 index 1234567..1234567 100644\n\
1053 --- a/test-file\n\
1054 +++ b/test-file\n";
1055 let mut parser = Parser::new(input);
1056 let diffs = parser.parse_diff().unwrap();
1057 assert_eq!(diffs.len(), 1, "Expected one diff block for mode change");
1058 assert_eq!(diffs[0].header.status, Status::Modified);
1059 }
1060
1061 #[test]
1062 fn mode_change_only() {
1063 let input = "diff --git a/script.sh b/script.sh\n\
1065 old mode 100644\n\
1066 new mode 100755\n";
1067 let mut parser = Parser::new(input);
1068 let diffs = parser.parse_diff().unwrap();
1069 assert_eq!(
1070 diffs.len(),
1071 1,
1072 "Expected one diff block for mode-only change"
1073 );
1074 assert_eq!(diffs[0].header.status, Status::Modified);
1075 assert_eq!(
1076 diffs[0].hunks.len(),
1077 0,
1078 "Expected no hunks for mode-only change"
1079 );
1080 }
1081
1082 #[test]
1083 fn commit() {
1084 let input = "commit 9318f4040de9e6cf60033f21f6ae91a0f2239d38\n\
1085 Author: altsem <alltidsemester@pm.me>\n\
1086 Date: Wed Feb 19 19:25:37 2025 +0100\n\
1087 \n\
1088 chore(release): prepare for v0.28.2\n\
1089 \n\
1090 diff --git a/.recent-changelog-entry b/.recent-changelog-entry\n\
1091 index 7c59f63..b3d843c 100644\n\
1092 --- a/.recent-changelog-entry\n\
1093 +++ b/.recent-changelog-entry\n\
1094 @@ -1,7 +1,6 @@\n\
1095 -## [0.28.1] - 2025-02-13\n\
1096 +## [0.28.2] - 2025-02-19\n ### 🐛 Bug Fixes\n \n\
1097 -- Change logging level to reduce inotify spam\n\
1098 -- Don't refresh on `gitu.log` writes (gitu --log)\n\
1099 +- Rebase menu opening after closing Neovim\n";
1100 let mut parser = Parser::new(input);
1101 let diffs = parser.parse_diff().unwrap();
1102 assert_eq!(
1103 &input[diffs[0].header.old_file.range.clone()],
1104 ".recent-changelog-entry"
1105 );
1106 assert_eq!(
1107 &input[diffs[0].header.new_file.range.clone()],
1108 ".recent-changelog-entry"
1109 );
1110 assert_eq!(diffs[0].header.status, Status::Modified);
1111 assert_eq!(
1112 &input[diffs[0].hunks[0].header.range.clone()],
1113 "@@ -1,7 +1,6 @@\n"
1114 );
1115 }
1116
1117 #[test]
1118 fn empty_commit() {
1119 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 ";
1120
1121 let mut parser = Parser::new(input);
1122 let diffs = parser.parse_diff().unwrap();
1123 assert_eq!(diffs.len(), 0);
1124 }
1125
1126 #[test]
1127 fn binary_file() {
1128 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";
1129
1130 let mut parser = Parser::new(input);
1131 let diffs = parser.parse_diff().unwrap();
1132 assert_eq!(diffs.len(), 3);
1133 }
1134
1135 #[test]
1136 fn conflicted_file() {
1137 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";
1138
1139 let mut parser = Parser::new(input);
1140 let diffs = parser.parse_diff().unwrap();
1141 assert_eq!(diffs.len(), 1);
1142 assert_eq!(diffs[0].header.status, Status::Unmerged);
1143 assert_eq!(&input[diffs[0].header.old_file.range.clone()], "new-file");
1144 assert_eq!(&input[diffs[0].header.new_file.range.clone()], "new-file");
1145 }
1146
1147 #[test]
1148 fn unmerged_path() {
1149 let input = "* Unmerged path new-file\n* Unmerged path new-file-2\n";
1150 let mut parser = Parser::new(input);
1151 let diff = parser.parse_diff().unwrap();
1152
1153 assert_eq!(diff.len(), 2);
1154 assert_eq!(diff[0].header.status, Status::Unmerged);
1155 assert_eq!(&input[diff[0].header.old_file.range.clone()], "new-file");
1156 assert_eq!(&input[diff[0].header.new_file.range.clone()], "new-file");
1157 assert!(diff[0].hunks.is_empty());
1158 assert_eq!(diff[1].header.status, Status::Unmerged);
1159 assert_eq!(&input[diff[1].header.old_file.range.clone()], "new-file-2");
1160 assert_eq!(&input[diff[1].header.new_file.range.clone()], "new-file-2");
1161 assert!(diff[1].hunks.is_empty());
1162 }
1163
1164 #[test]
1165 fn missing_newline_before_final() {
1166 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+});";
1167
1168 let mut parser = Parser::new(input);
1169 let diffs = parser.parse_diff().unwrap();
1170 assert_eq!(diffs.len(), 1);
1171 assert_eq!(diffs[0].header.status, Status::Modified);
1172 assert_eq!(diffs[0].hunks.len(), 1);
1173 let changes = &diffs[0].hunks[0].content.changes;
1174 assert_eq!(changes.len(), 1);
1175 assert_eq!(
1176 &input[changes[0].old.clone()],
1177 "-})\n\\ No newline at end of file\n"
1178 );
1179 assert_eq!(&input[changes[0].new.clone()], "+});");
1180 }
1181
1182 #[test]
1183 fn filenames_with_spaces() {
1184 let input = "\
1185 diff --git a/file one.txt b/file two.txt\n\
1186 index 5626abf..f719efd 100644\n\
1187 @@ -1 +1 @@\n\
1188 -one\n\
1189 +two\n\
1190 ";
1191 let mut parser = Parser::new(input);
1192 let diff = parser.parse_diff().unwrap();
1193 assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1194 assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1195 }
1196
1197 #[test]
1212 fn added_file_with_spaces_in_name() {
1213 let input = "diff --git a/something with space.md b/something with space.md\n\
1214 new file mode 100644\n\
1215 index 0000000..e69de29\n";
1216 let mut parser = Parser::new(input);
1217 let diff = parser.parse_diff().unwrap();
1218 assert_eq!(diff.len(), 1);
1219 assert_eq!(diff[0].header.status, Status::Added);
1220 assert_eq!(
1221 diff[0].header.old_file.fmt(input),
1222 "something with space.md"
1223 );
1224 assert_eq!(
1225 diff[0].header.new_file.fmt(input),
1226 "something with space.md"
1227 );
1228 }
1229
1230 #[test]
1231 fn utilizes_other_old_new_header_when_ambiguous() {
1232 let input = "\
1233 diff --git a/file one.txt b/file two.txt\n\
1234 index 5626abf..f719efd 100644\n\
1235 --- a/file one.txt\n\
1236 +++ b/file two.txt\n\
1237 @@ -1 +1 @@\n\
1238 -one\n\
1239 +two\n\
1240 ";
1241
1242 let mut parser = Parser::new(input);
1243 let diff = parser.parse_diff().unwrap();
1244 assert_eq!(diff[0].header.old_file.fmt(input), "file one.txt");
1245 assert_eq!(diff[0].header.new_file.fmt(input), "file two.txt");
1246 }
1247
1248 #[test]
1249 fn partially_unmerged() {
1250 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";
1251
1252 let mut parser = Parser::new(input);
1253 let diffs = parser.parse_diff().unwrap();
1254 assert_eq!(diffs.len(), 3);
1255 assert_eq!(diffs[2].header.status, Status::Unmerged);
1256 assert_eq!(
1257 &input[diffs[2].header.old_file.range.clone()],
1258 "src/ops/show.rs"
1259 );
1260 assert_eq!(
1261 &input[diffs[2].header.new_file.range.clone()],
1262 "src/ops/show.rs"
1263 );
1264 }
1265
1266 #[test]
1267 fn parse_custom_prefixes() {
1268 let input = "diff --git i/file1.txt w/file2.txt\n\
1269 index 0000000..1111111 100644\n\
1270 --- i/file1.txt\n\
1271 +++ w/file2.txt\n\
1272 @@ -1,2 +1,2 @@ fn main() {\n\
1273 -foo\n\
1274 +bar\n";
1275 let mut parser = Parser::new(input);
1276 let diffs = parser.parse_diff().unwrap();
1277 assert_eq!(diffs.len(), 1, "Expected one diff block");
1278
1279 let diff = &diffs[0];
1280 let old_file_str = diff.header.old_file.fmt(input);
1281 assert_eq!(old_file_str, "file1.txt", "Old file does not match");
1282
1283 let new_file_str = diff.header.new_file.fmt(input);
1284 assert_eq!(new_file_str, "file2.txt", "New file does not match");
1285 }
1286
1287 #[test]
1288 fn parse_quoted_filenames() {
1289 let input = "\
1290 diff --git \"a/\\303\\266\" \"b/\\303\\266\"\n\
1291 new file mode 100644\n\
1292 index 0000000..e69de29\n\
1293 diff --git \"a/\\\"\" \"b/\\\\\"\n\
1294 new file mode 100644\n\
1295 index 0000000..e69de29\n\
1296 diff --git \"a/\\'\" \"b/\\v\"\n\
1297 new file mode 100644\n\
1298 index 0000000..e69de29\n\
1299 diff --git \"a/\\t\" \"b/\\r\"\n\
1300 new file mode 100644\n\
1301 index 0000000..e69de29\n\
1302 diff --git \"a/\\n\" \"b/\\f\"\n\
1303 new file mode 100644\n\
1304 index 0000000..e69de29\n\
1305 diff --git \"a/\\E\" \"b/\\e\"\n\
1306 new file mode 100644\n\
1307 index 0000000..e69de29\n\
1308 diff --git \"a/\\e\" \"b/\\b\"\n\
1309 new file mode 100644\n\
1310 index 0000000..e69de29\n\
1311 diff --git \"a/\\a\" \"b/\\a\"\n\
1312 new file mode 100644\n\
1313 index 0000000..e69de29\n\
1314 diff --git \"a/l\\303\\266l space\" \"b/l\\303\\266l space\"\n\
1315 new file mode 100644\n\
1316 index 0000000..e69de29";
1317
1318 let mut parser = Parser::new(input);
1319 let diffs = parser.parse_diff().unwrap();
1320 assert_eq!(diffs.len(), 9);
1321 assert_eq!(diffs[0].header.old_file.fmt(input), "ö", "Old file");
1322 assert_eq!(diffs[0].header.new_file.fmt(input), "ö", "New file");
1323 assert_eq!(diffs[1].header.old_file.fmt(input), "\"", "Old file");
1324 assert_eq!(diffs[1].header.new_file.fmt(input), "\\", "New file");
1325 assert_eq!(diffs[2].header.old_file.fmt(input), "'", "Old file");
1326 assert_eq!(diffs[2].header.new_file.fmt(input), "\u{b}", "New file");
1327 assert_eq!(diffs[3].header.old_file.fmt(input), "\t", "Old file");
1328 assert_eq!(diffs[3].header.new_file.fmt(input), "\r", "New file");
1329 assert_eq!(diffs[4].header.old_file.fmt(input), "\n", "Old file");
1330 assert_eq!(diffs[4].header.new_file.fmt(input), "\u{c}", "New file");
1331 assert_eq!(diffs[8].header.old_file.fmt(input), "löl space", "Old file");
1332 assert_eq!(diffs[8].header.new_file.fmt(input), "löl space", "New file");
1333 }
1334
1335 #[test]
1336 fn errors_when_bad_escaped_char() {
1337 let input = "\
1338 diff --git \"a/\\y\" \"b/\\y\"\n\
1339 new file mode 100644\n\
1340 ";
1341
1342 let mut parser = Parser::new(input);
1343 assert!(parser.parse_diff().is_err());
1344 }
1345
1346 #[test]
1347 fn parse_header_noprefix() {
1348 let input = "\
1349 diff --git Cargo.lock Cargo.lock\n\
1350 index 1f88e5a..3b8ea64 100644\n\
1351 --- Cargo.lock\n\
1352 +++ Cargo.lock";
1353
1354 let mut parser = Parser::new(input);
1355 let diffs = parser.parse_diff().unwrap();
1356 assert_eq!(diffs.len(), 1, "Expected one diff block");
1357
1358 let diff = &diffs[0];
1359 let old_file_str = diff.header.old_file.fmt(input);
1360 assert_eq!(old_file_str, "Cargo.lock", "Old file does not match");
1361
1362 let new_file_str = diff.header.new_file.fmt(input);
1363 assert_eq!(new_file_str, "Cargo.lock", "New file does not match");
1364 }
1365}