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    /// Get the ranges of changed lines based on the `lines_changed_only` parameter.
114    ///
115    /// Use this to map [`Self::added_lines`] and [`Self::diff_hunks`] to a selection of
116    /// [`LinesChangedOnly`] options.
117    pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Option<Vec<Range<u32>>> {
118        match lines_changed_only {
119            LinesChangedOnly::Diff => Some(self.diff_hunks.to_vec()),
120            LinesChangedOnly::On => Some(self.added_ranges.to_vec()),
121            _ => None,
122        }
123    }
124
125    /// Is the range from [`DiffHunkHeader`] contained in a single item of
126    /// [`FileDiffLines::diff_hunks`]?
127    pub fn is_hunk_in_diff(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
128        let (start_line, end_line) = if hunk.old_lines > 0 {
129            // if old hunk's total lines is > 0
130            let start = hunk.old_start;
131            (start, start + hunk.old_lines)
132        } else {
133            // old hunk's total lines is 0, meaning changes were only added
134            let start = hunk.new_start;
135            // make old hunk's range span 1 line
136            (start, start + 1)
137        };
138        let inclusive_end = end_line - 1;
139        for range in &self.diff_hunks {
140            if range.contains(&start_line) && range.contains(&inclusive_end) {
141                return Some((start_line, end_line));
142            }
143        }
144        None
145    }
146
147    /// Similar to [`FileDiffLines::is_hunk_in_diff()`] but looks for a single line instead of
148    /// all lines in a [`DiffHunkHeader`].
149    pub fn is_line_in_diff(&self, line: &u32) -> bool {
150        for range in &self.diff_hunks {
151            if range.contains(line) {
152                return true;
153            }
154        }
155        false
156    }
157}
158
159#[cfg(test)]
160mod test {
161    #![allow(clippy::unwrap_used)]
162
163    use super::{FileDiffLines, LinesChangedOnly};
164
165    #[test]
166    fn display_lines_changed_only() {
167        assert_eq!(LinesChangedOnly::Off.to_string(), "false");
168        assert_eq!(LinesChangedOnly::Diff.to_string(), "diff");
169        assert_eq!(LinesChangedOnly::On.to_string(), "true");
170    }
171
172    #[test]
173    fn get_ranges_none() {
174        let file_obj = FileDiffLines::default();
175        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
176        assert!(ranges.is_none());
177    }
178
179    #[test]
180    fn get_ranges_diff() {
181        #[allow(clippy::single_range_in_vec_init)]
182        let diff_chunks = vec![1..11];
183        let added_lines = vec![4, 5, 9];
184        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks.clone());
185        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
186        assert_eq!(ranges.unwrap(), diff_chunks);
187    }
188
189    #[test]
190    fn get_ranges_added() {
191        #[allow(clippy::single_range_in_vec_init)]
192        let diff_chunks = vec![1..11];
193        let added_lines = vec![4, 5, 9];
194        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks);
195        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
196        assert_eq!(ranges.unwrap(), vec![4..6, 9..10]);
197    }
198
199    #[test]
200    fn line_not_in_diff() {
201        let file_obj = FileDiffLines::default();
202        assert!(!file_obj.is_line_in_diff(&42));
203    }
204}