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