gnostr_asyncgit/gitui/gitu_diff/
mod.rs

1/// A git diff parser.
2///
3/// The aim of this module is to produce ranges that refer to the original input bytes.
4/// This approach can be preferable where one would want to:
5/// - Use the ranges to highlight changes in a user interface.
6/// - Be sure that the original input is intact.
7///
8use 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    /// Parses a diff file and returns a vector of Diff structures.
151    ///
152    /// The returned ranges refer to the original input bytes.
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// let input = "diff --git a/file1.txt b/file2.txt\n\
158    /// index 0000000..1111111 100644\n\
159    /// --- a/file1.txt\n\
160    /// +++ b/file2.txt\n\
161    /// @@ -1,2 +1,2 @@\n\
162    /// -foo\n\
163    /// +bar\n";
164    ///
165    /// let diff = gnostr_asyncgit::gitui::gitu_diff::Parser::new(input).parse_diff().unwrap();
166    /// assert_eq!(diff[0].header.new_file, 25..34); // "file2.txt"
167    /// ```
168    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    /// Scans through the input, moving the cursor byte-by-byte
510    /// until the provided parse_fn will succeed, or the input has been exhausted.
511    /// Returns a tuple of the bytes scanned up until the match, and the match itself.
512    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    /// Scans through the input byte-by-byte
523    /// until the provided parse_fn will succeed, or the input has been exhausted.
524    /// Returning the match. Does not step the parser.
525    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    /// Consumes `expected` from the input and moves the cursor past it.
558    /// Returns an error if `expected` was not found at the cursor.
559    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    /// Returns true if `expected` is found at the cursor.
574    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}