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    ///
133    /// Useful to see if a hunk of a proposed patch falls within the diff of
134    /// the current commit's changes.
135    pub fn is_hunk_in_diff(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
136        let (start_line, end_line) = if hunk.old_lines > 0 {
137            // if old hunk's total lines is > 0
138            let start = hunk.old_start;
139            (start, start + hunk.old_lines)
140        } else {
141            // old hunk's total lines is 0, meaning changes were only added
142            let start = hunk.new_start;
143            // make old hunk's range span 1 line
144            (start, start + 1)
145        };
146        let inclusive_end = end_line - 1;
147        for range in &self.diff_hunks {
148            if range.contains(&start_line) && range.contains(&inclusive_end) {
149                return Some((start_line, end_line));
150            }
151        }
152        None
153    }
154
155    /// Similar to [`FileDiffLines::is_hunk_in_diff()`] but looks for a single line instead of
156    /// all lines in a [`DiffHunkHeader`].
157    pub fn is_line_in_diff(&self, line: &u32) -> bool {
158        for range in &self.diff_hunks {
159            if range.contains(line) {
160                return true;
161            }
162        }
163        false
164    }
165}
166
167#[cfg(feature = "pyo3")]
168#[pymethods]
169impl FileDiffLines {
170    /// Create a new file diff lines instance.
171    ///
172    /// The ``added_ranges`` and ``diff_hunks`` are provided as
173    /// tuples of ``(start, end)`` to represent ranges.
174    #[new]
175    #[pyo3(
176        signature = (added_lines, added_ranges, diff_hunks),
177        text_signature = "(added_lines: list[int], added_ranges: list[tuple[int, int]], diff_hunks: list[tuple[int, int]])"
178    )]
179    pub fn new_py(
180        added_lines: Vec<u32>,
181        added_ranges: Vec<(u32, u32)>,
182        diff_hunks: Vec<(u32, u32)>,
183    ) -> Self {
184        Self {
185            added_lines,
186            added_ranges: added_ranges
187                .into_iter()
188                .map(|(start, end)| start..end)
189                .collect(),
190            diff_hunks: diff_hunks
191                .into_iter()
192                .map(|(start, end)| start..end)
193                .collect(),
194        }
195    }
196
197    /// Create a new file diff lines instance from given ``added_lines`` and ``diff_hunks``.
198    ///
199    /// This constructor is preferred because the ``added_ranges`` is automatically
200    /// calculated from the ``added_lines``.
201    #[staticmethod]
202    #[pyo3(
203        signature = (added_lines, diff_hunks),
204        text_signature = "(added_lines: list[int], diff_hunks: list[tuple[int, int]]) -> FileDiffLines"
205    )]
206    pub fn from_info(added_lines: Vec<u32>, diff_hunks: Vec<(u32, u32)>) -> Self {
207        Self::with_info(
208            added_lines,
209            diff_hunks
210                .into_iter()
211                .map(|(start, end)| start..end)
212                .collect(),
213        )
214    }
215
216    /// The range of line numbers whose lines were added.
217    ///
218    /// This takes the form of a list of tuples of
219    /// ``(inclusive_start, exclusive_end)`` to represent ranges.
220    #[getter]
221    pub fn get_added_ranges(&self) -> Vec<(u32, u32)> {
222        self.added_ranges
223            .iter()
224            .map(|range| (range.start, range.end))
225            .collect()
226    }
227
228    /// The list of line numbers whose lines have additions.
229    #[getter]
230    pub fn get_added_lines(&self) -> Vec<u32> {
231        self.added_lines.clone()
232    }
233
234    /// The range of line numbers that span the diff hunks.
235    ///
236    /// This takes the form of a list of tuples of
237    /// ``(inclusive_start, exclusive_end)`` to represent ranges.
238    #[getter]
239    pub fn get_diff_hunks(&self) -> Vec<(u32, u32)> {
240        self.diff_hunks
241            .iter()
242            .map(|range| (range.start, range.end))
243            .collect()
244    }
245
246    /// Check if the given hunk header describes a hunk contained in the ``diff_hunks``.
247    #[pyo3(
248        name = "is_hunk_in_diff",
249        signature = (hunk),
250        text_signature = "(hunk: DiffHunkHeader) -> tuple[int, int] | None"
251    )]
252    pub fn is_hunk_in_diff_py(&self, hunk: &DiffHunkHeader) -> Option<(u32, u32)> {
253        self.is_hunk_in_diff(hunk)
254    }
255
256    /// Check if the given line number is contained in the ``diff_hunks``.
257    #[pyo3(
258        name = "is_line_in_diff",
259        signature = (line),
260        text_signature = "(line: int) -> bool"
261    )]
262    pub fn is_line_in_diff_py(&self, line: u32) -> bool {
263        self.is_line_in_diff(&line)
264    }
265}
266
267#[cfg(test)]
268mod test {
269    #![allow(clippy::unwrap_used)]
270
271    use super::{FileDiffLines, LinesChangedOnly};
272
273    #[test]
274    fn display_lines_changed_only() {
275        assert_eq!(LinesChangedOnly::Off.to_string(), "false");
276        assert_eq!(LinesChangedOnly::Diff.to_string(), "diff");
277        assert_eq!(LinesChangedOnly::On.to_string(), "true");
278    }
279
280    #[test]
281    fn get_ranges_none() {
282        let file_obj = FileDiffLines::default();
283        let ranges = file_obj.get_ranges(&LinesChangedOnly::Off);
284        assert!(ranges.is_none());
285    }
286
287    #[test]
288    fn get_ranges_diff() {
289        #[allow(clippy::single_range_in_vec_init)]
290        let diff_chunks = vec![1..11];
291        let added_lines = vec![4, 5, 9];
292        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks.clone());
293        let ranges = file_obj.get_ranges(&LinesChangedOnly::Diff);
294        assert_eq!(ranges.unwrap(), diff_chunks);
295    }
296
297    #[test]
298    fn get_ranges_added() {
299        #[allow(clippy::single_range_in_vec_init)]
300        let diff_chunks = vec![1..11];
301        let added_lines = vec![4, 5, 9];
302        let file_obj = FileDiffLines::with_info(added_lines, diff_chunks);
303        let ranges = file_obj.get_ranges(&LinesChangedOnly::On);
304        assert_eq!(ranges.unwrap(), vec![4..6, 9..10]);
305    }
306
307    #[test]
308    fn line_not_in_diff() {
309        let file_obj = FileDiffLines::default();
310        assert!(!file_obj.is_line_in_diff(&42));
311    }
312}