Skip to main content

git_bot_feedback/file_utils/
mod.rs

1#[cfg(feature = "pyo3")]
2use pyo3::prelude::*;
3
4use std::ops::Range;
5
6pub mod file_filter;
7use crate::DiffHunkHeader;
8
9/// An enum to help determine what constitutes a changed file based on the diff contents.
10#[derive(PartialEq, Clone, Copy, Debug, Default)]
11#[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
12#[cfg_attr(feature = "pyo3", pyclass(module = "git_bot_feedback", from_py_object))]
13pub enum LinesChangedOnly {
14    /// File is included regardless of changed lines in the diff.
15    ///
16    /// Use [`FileFilter`](crate::FileFilter) to filter files by
17    /// extension and/or path.
18    #[default]
19    Off,
20
21    /// Only include files with lines in the diff.
22    ///
23    /// Note, this *includes* files that only have lines with deletions.
24    /// But, this *excludes* files that have no line changes at all
25    /// (eg. renamed files with unmodified contents, or deleted files, or
26    /// binary files).
27    Diff,
28
29    /// Only include files with lines in the diff that have additions.
30    ///
31    /// Note, this *excludes* files that only have lines with deletions.
32    /// So, this is like [`LinesChangedOnly::Diff`] but stricter.
33    On,
34}
35
36impl LinesChangedOnly {
37    pub(crate) fn is_change_valid(&self, added_lines: bool, diff_hunks: bool) -> bool {
38        match self {
39            LinesChangedOnly::Off => true,
40            LinesChangedOnly::Diff => diff_hunks,
41            LinesChangedOnly::On => added_lines,
42        }
43    }
44}
45
46impl std::fmt::Display for LinesChangedOnly {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            LinesChangedOnly::Off => write!(f, "false"),
50            LinesChangedOnly::Diff => write!(f, "diff"),
51            LinesChangedOnly::On => write!(f, "true"),
52        }
53    }
54}
55
56/// A structure to represent a file's changes per line numbers.
57#[derive(Debug, Clone, Default)]
58#[cfg_attr(docsrs, doc(cfg(feature = "file-changes")))]
59#[cfg_attr(feature = "pyo3", pyclass(module = "git_bot_feedback", from_py_object))]
60pub struct FileDiffLines {
61    /// The list of lines numbers with additions.
62    pub added_lines: Vec<u32>,
63
64    /// The list of ranges that span only lines numbers with additions.
65    ///
66    /// The line numbers here disregard the old line numbers in the diff hunks.
67    /// Each range describes the beginning and ending of a group of consecutive line numbers.
68    pub added_ranges: Vec<Range<u32>>,
69
70    /// The list of ranges that span the lines numbers present in diff chunks.
71    ///
72    /// The line numbers here disregard the old line numbers in the diff hunks.
73    pub diff_hunks: Vec<Range<u32>>,
74}
75
76impl FileDiffLines {
77    /// Instantiate an object with changed lines information.
78    pub fn with_info(added_lines: Vec<u32>, diff_hunks: Vec<Range<u32>>) -> Self {
79        let added_ranges = Self::consolidate_numbers_to_ranges(&added_lines);
80        Self {
81            added_lines,
82            added_ranges,
83            diff_hunks,
84        }
85    }
86
87    /// A helper function to consolidate a [Vec<u32>] of line numbers into a
88    /// [Vec<Range<u32>>] in which each range describes the beginning and
89    /// ending of a group of consecutive line numbers.
90    fn consolidate_numbers_to_ranges(lines: &[u32]) -> Vec<Range<u32>> {
91        let mut iter_lines = lines.iter().enumerate();
92        if let Some((_, start)) = iter_lines.next() {
93            let mut range_start = *start;
94            let mut ranges: Vec<Range<u32>> = Vec::new();
95            let last_entry = lines.len() - 1;
96            for (index, number) in iter_lines {
97                if let Some(prev) = lines.get(index - 1)
98                    && (number - 1) != *prev
99                {
100                    // non-consecutive number found
101                    // push the previous range
102                    ranges.push(range_start..(*prev + 1));
103                    // and start a new range
104                    // from the current number
105                    range_start = *number;
106                }
107                if index == last_entry {
108                    // last number
109                    ranges.push(range_start..(*number + 1));
110                }
111            }
112            ranges
113        } else {
114            Vec::new()
115        }
116    }
117
118    /// Get the ranges of changed lines based on the `lines_changed_only` parameter.
119    ///
120    /// Use this to map [`Self::added_lines`] and [`Self::diff_hunks`] to a selection of
121    /// [`LinesChangedOnly`] options.
122    pub fn get_ranges(&self, lines_changed_only: &LinesChangedOnly) -> Option<Vec<Range<u32>>> {
123        match lines_changed_only {
124            LinesChangedOnly::Diff => Some(self.diff_hunks.to_vec()),
125            LinesChangedOnly::On => Some(self.added_ranges.to_vec()),
126            _ => None,
127        }
128    }
129
130    /// Is the range from [`DiffHunkHeader`] contained in a single item of
131    /// [`FileDiffLines::diff_hunks`]?
132    pub fn is_hunk_in_diff(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
133        let (start_line, end_line) = if hunk.old_lines > 0 {
134            // if old hunk's total lines is > 0
135            let start = hunk.old_start;
136            (start, start + hunk.old_lines)
137        } else {
138            // old hunk's total lines is 0, meaning changes were only added
139            let start = hunk.new_start;
140            // make old hunk's range span 1 line
141            (start, start + 1)
142        };
143        let inclusive_end = end_line - 1;
144        for range in &self.diff_hunks {
145            if range.contains(&start_line) && range.contains(&inclusive_end) {
146                return Some((start_line, end_line));
147            }
148        }
149        None
150    }
151
152    /// Similar to [`FileDiffLines::is_hunk_in_diff()`] but looks for a single line instead of
153    /// all lines in a [`DiffHunkHeader`].
154    pub fn is_line_in_diff(&self, line: &u32) -> bool {
155        for range in &self.diff_hunks {
156            if range.contains(line) {
157                return true;
158            }
159        }
160        false
161    }
162}
163
164#[cfg(feature = "pyo3")]
165#[pymethods]
166impl FileDiffLines {
167    /// Create a new file diff lines instance.
168    ///
169    /// The ``added_ranges`` and ``diff_hunks`` are provided as
170    /// tuples of ``(start, end)`` to represent ranges.
171    #[new]
172    #[pyo3(
173        signature = (added_lines, added_ranges, diff_hunks),
174        text_signature = "(added_lines: list[int], added_ranges: list[tuple[int, int]], diff_hunks: list[tuple[int, int]])"
175    )]
176    pub fn new_py(
177        added_lines: Vec<u32>,
178        added_ranges: Vec<(u32, u32)>,
179        diff_hunks: Vec<(u32, u32)>,
180    ) -> Self {
181        Self {
182            added_lines,
183            added_ranges: added_ranges
184                .into_iter()
185                .map(|(start, end)| start..end)
186                .collect(),
187            diff_hunks: diff_hunks
188                .into_iter()
189                .map(|(start, end)| start..end)
190                .collect(),
191        }
192    }
193
194    /// Create a new file diff lines instance from given ``added_lines`` and ``diff_hunks``.
195    ///
196    /// This constructor is preferred because the ``added_ranges`` is automatically
197    /// calculated from the ``added_lines``.
198    #[staticmethod]
199    #[pyo3(
200        signature = (added_lines, diff_hunks),
201        text_signature = "(added_lines: list[int], diff_hunks: list[tuple[int, int]]) -> FileDiffLines"
202    )]
203    pub fn from_info(added_lines: Vec<u32>, diff_hunks: Vec<(u32, u32)>) -> Self {
204        Self::with_info(
205            added_lines,
206            diff_hunks
207                .into_iter()
208                .map(|(start, end)| start..end)
209                .collect(),
210        )
211    }
212
213    /// The range of line numbers whose lines were added.
214    ///
215    /// This takes the form of a list of tuples of
216    /// ``(inclusive_start, exclusive_end)`` to represent ranges.
217    #[getter]
218    pub fn get_added_ranges(&self) -> Vec<(u32, u32)> {
219        self.added_ranges
220            .iter()
221            .map(|range| (range.start, range.end))
222            .collect()
223    }
224
225    /// The list of line numbers whose lines have additions.
226    #[getter]
227    pub fn get_added_lines(&self) -> Vec<u32> {
228        self.added_lines.clone()
229    }
230
231    /// The range of line numbers that span the diff hunks.
232    ///
233    /// This takes the form of a list of tuples of
234    /// ``(inclusive_start, exclusive_end)`` to represent ranges.
235    #[getter]
236    pub fn get_diff_hunks(&self) -> Vec<(u32, u32)> {
237        self.diff_hunks
238            .iter()
239            .map(|range| (range.start, range.end))
240            .collect()
241    }
242
243    /// Check if the given hunk header describes a hunk contained in the ``diff_hunks``.
244    #[pyo3(
245        name = "is_hunk_in_diff",
246        signature = (hunk),
247        text_signature = "(hunk: DiffHunkHeader) -> tuple[int, int] | None"
248    )]
249    pub fn is_hunk_in_diff_py(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
250        self.is_hunk_in_diff(hunk)
251    }
252
253    /// Check if the given line number is contained in the ``diff_hunks``.
254    #[pyo3(
255        name = "is_line_in_diff",
256        signature = (line),
257        text_signature = "(line: int) -> bool"
258    )]
259    pub fn is_line_in_diff_py(&self, line: u32) -> bool {
260        self.is_line_in_diff(&line)
261    }
262}
263
264#[cfg(test)]
265mod test {
266    #![allow(clippy::unwrap_used)]
267
268    use super::{FileDiffLines, LinesChangedOnly};
269
270    #[test]
271    fn display_lines_changed_only() {
272        assert_eq!(LinesChangedOnly::Off.to_string(), "false");
273        assert_eq!(LinesChangedOnly::Diff.to_string(), "diff");
274        assert_eq!(LinesChangedOnly::On.to_string(), "true");
275    }
276
277    #[test]
278    fn get_ranges_none() {
279        let file_obj = FileDiffLines::default();
280        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
281        assert!(ranges.is_none());
282    }
283
284    #[test]
285    fn get_ranges_diff() {
286        #[allow(clippy::single_range_in_vec_init)]
287        let diff_chunks = vec![1..11];
288        let added_lines = vec![4, 5, 9];
289        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks.clone());
290        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
291        assert_eq!(ranges.unwrap(), diff_chunks);
292    }
293
294    #[test]
295    fn get_ranges_added() {
296        #[allow(clippy::single_range_in_vec_init)]
297        let diff_chunks = vec![1..11];
298        let added_lines = vec![4, 5, 9];
299        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks);
300        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
301        assert_eq!(ranges.unwrap(), vec![4..6, 9..10]);
302    }
303
304    #[test]
305    fn line_not_in_diff() {
306        let file_obj = FileDiffLines::default();
307        assert!(!file_obj.is_line_in_diff(&42));
308    }
309}