Skip to main content

git_bot_feedback/file_utils/
mod.rs

1use std::ops::Range;
2
3pub mod file_filter;
4use crate::DiffHunkHeader;
5
6/// An enum to help determine what constitutes a changed file based on the diff contents.
7#[derive(PartialEq, Clone, Copy, Debug, Default)]
8#[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
9pub enum LinesChangedOnly {
10    /// File is included regardless of changed lines in the diff.
11    ///
12    /// Use [`FileFilter`](crate::FileFilter) to filter files by
13    /// extension and/or path.
14    #[default]
15    Off,
16
17    /// Only include files with lines in the diff.
18    ///
19    /// Note, this *includes* files that only have lines with deletions.
20    /// But, this *excludes* files that have no line changes at all
21    /// (eg. renamed files with unmodified contents, or deleted files, or
22    /// binary files).
23    Diff,
24
25    /// Only include files with lines in the diff that have additions.
26    ///
27    /// Note, this *excludes* files that only have lines with deletions.
28    /// So, this is like [`LinesChangedOnly::Diff`] but stricter.
29    On,
30}
31
32impl LinesChangedOnly {
33    pub(crate) fn is_change_valid(&self, added_lines: bool, diff_hunks: bool) -> bool {
34        match self {
35            LinesChangedOnly::Off => true,
36            LinesChangedOnly::Diff => diff_hunks,
37            LinesChangedOnly::On => added_lines,
38        }
39    }
40}
41
42impl std::fmt::Display for LinesChangedOnly {
43    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44        match self {
45            LinesChangedOnly::Off => write!(f, "false"),
46            LinesChangedOnly::Diff => write!(f, "diff"),
47            LinesChangedOnly::On => write!(f, "true"),
48        }
49    }
50}
51
52/// A structure to represent a file's changes per line numbers.
53#[derive(Debug, Clone, Default)]
54#[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
55pub struct FileDiffLines {
56    /// The list of lines numbers with additions.
57    pub added_lines: Vec<u32>,
58
59    /// The list of ranges that span only lines numbers with additions.
60    ///
61    /// The line numbers here disregard the old line numbers in the diff hunks.
62    /// Each range describes the beginning and ending of a group of consecutive line numbers.
63    pub added_ranges: Vec<Range<u32>>,
64
65    /// The list of ranges that span the lines numbers present in diff chunks.
66    ///
67    /// The line numbers here disregard the old line numbers in the diff hunks.
68    pub diff_hunks: Vec<Range<u32>>,
69}
70
71impl FileDiffLines {
72    /// Instantiate an object with changed lines information.
73    pub fn with_info(added_lines: Vec<u32>, diff_chunks: Vec<Range<u32>>) -> Self {
74        let added_ranges = Self::consolidate_numbers_to_ranges(&added_lines);
75        Self {
76            added_lines,
77            added_ranges,
78            diff_hunks: diff_chunks,
79        }
80    }
81
82    /// A helper function to consolidate a [Vec<u32>] of line numbers into a
83    /// [Vec<Range<u32>>] in which each range describes the beginning and
84    /// ending of a group of consecutive line numbers.
85    fn consolidate_numbers_to_ranges(lines: &[u32]) -> Vec<Range<u32>> {
86        let mut iter_lines = lines.iter().enumerate();
87        if let Some((_, start)) = iter_lines.next() {
88            let mut range_start = *start;
89            let mut ranges: Vec<Range<u32>> = Vec::new();
90            let last_entry = lines.len() - 1;
91            for (index, number) in iter_lines {
92                if let Some(prev) = lines.get(index - 1)
93                    && (number - 1) != *prev
94                {
95                    // non-consecutive number found
96                    // push the previous range
97                    ranges.push(range_start..(*prev + 1));
98                    // and start a new range
99                    // from the current number
100                    range_start = *number;
101                }
102                if index == last_entry {
103                    // last number
104                    ranges.push(range_start..(*number + 1));
105                }
106            }
107            ranges
108        } else {
109            Vec::new()
110        }
111    }
112
113    pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Option<Vec<Range<u32>>> {
114        match lines_changed_only {
115            LinesChangedOnly::Diff => Some(self.diff_hunks.to_vec()),
116            LinesChangedOnly::On => Some(self.added_ranges.to_vec()),
117            _ => None,
118        }
119    }
120
121    /// Is the range from [`DiffHunkHeader`] contained in a single item of
122    /// [`FileDiffLines::diff_hunks`]?
123    pub fn is_hunk_in_diff(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
124        let (start_line, end_line) = if hunk.old_lines > 0 {
125            // if old hunk's total lines is > 0
126            let start = hunk.old_start;
127            (start, start + hunk.old_lines)
128        } else {
129            // old hunk's total lines is 0, meaning changes were only added
130            let start = hunk.new_start;
131            // make old hunk's range span 1 line
132            (start, start + 1)
133        };
134        let inclusive_end = end_line - 1;
135        for range in &self.diff_hunks {
136            if range.contains(&start_line) && range.contains(&inclusive_end) {
137                return Some((start_line, end_line));
138            }
139        }
140        None
141    }
142
143    /// Similar to [`FileDiffLines::is_hunk_in_diff()`] but looks for a single line instead of
144    /// all lines in a [`DiffHunkHeader`].
145    pub fn is_line_in_diff(&self, line: &u32) -> bool {
146        for range in &self.diff_hunks {
147            if range.contains(line) {
148                return true;
149            }
150        }
151        false
152    }
153}
154
155#[cfg(test)]
156mod test {
157    use super::{FileDiffLines, LinesChangedOnly};
158
159    #[test]
160    fn display_lines_changed_only() {
161        assert_eq!(LinesChangedOnly::Off.to_string(), "false");
162        assert_eq!(LinesChangedOnly::Diff.to_string(), "diff");
163        assert_eq!(LinesChangedOnly::On.to_string(), "true");
164    }
165
166    #[test]
167    fn get_ranges_none() {
168        let file_obj = FileDiffLines::default();
169        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
170        assert!(ranges.is_none());
171    }
172
173    #[test]
174    fn get_ranges_diff() {
175        #[allow(clippy::single_range_in_vec_init)]
176        let diff_chunks = vec![1..11];
177        let added_lines = vec![4, 5, 9];
178        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks.clone());
179        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
180        assert_eq!(ranges.unwrap(), diff_chunks);
181    }
182
183    #[test]
184    fn get_ranges_added() {
185        #[allow(clippy::single_range_in_vec_init)]
186        let diff_chunks = vec![1..11];
187        let added_lines = vec![4, 5, 9];
188        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks);
189        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
190        assert_eq!(ranges.unwrap(), vec![4..6, 9..10]);
191    }
192
193    #[test]
194    fn line_not_in_diff() {
195        let file_obj = FileDiffLines::default();
196        assert!(!file_obj.is_line_in_diff(&42));
197    }
198}