1#[cfg(feature = "pyo3")]
2use pyo3::prelude::*;
3
4use regex::Regex;
5use std::{collections::HashMap, ops::Range, path::Path};
6
7use crate::{FileDiffLines, FileFilter, LinesChangedOnly, error::DiffError};
8
9#[cfg_attr(
11 feature = "pyo3",
12 pyclass(module = "git_bot_feedback", from_py_object, get_all, frozen)
13)]
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub struct DiffHunkHeader {
16 pub old_start: u32,
18 pub old_lines: u32,
20 pub new_start: u32,
22 pub new_lines: u32,
24}
25
26#[cfg(feature = "pyo3")]
27#[pymethods]
28impl DiffHunkHeader {
29 #[new]
31 #[pyo3(text_signature = "(old_start: int, old_lines: int, new_start: int, new_lines: int)")]
32 pub fn new_py(old_start: i64, old_lines: i64, new_start: i64, new_lines: i64) -> Self {
33 Self {
34 old_start: old_start.clamp(0, u32::MAX as i64) as u32,
35 old_lines: old_lines.clamp(0, u32::MAX as i64) as u32,
36 new_start: new_start.clamp(0, u32::MAX as i64) as u32,
37 new_lines: new_lines.clamp(0, u32::MAX as i64) as u32,
38 }
39 }
40}
41
42fn get_filename_from_front_matter(front_matter: &str) -> Result<Option<&str>, DiffError> {
43 let diff_file_name = Regex::new(r"(?m)^\+\+\+\sb?/(.*)$")?;
44 if let Some(captures) = diff_file_name.captures(front_matter)
45 && let Some(name) = captures.get(1)
46 {
47 return Ok(Some(name.as_str().trim_end_matches(['\r', '\n'])));
48 }
49 let diff_renamed_file = Regex::new(r"(?m)^rename to (.*)$")?;
50 if front_matter.starts_with("similarity")
51 && let Some(captures) = diff_renamed_file.captures(front_matter)
52 && let Some(name) = captures.get(1)
53 {
54 return Ok(Some(name.as_str().trim_end_matches(['\r', '\n'])));
55 }
56 let diff_binary_file = Regex::new(r"(?m)^Binary\sfiles\s")?;
57 let diff_mode_only = Regex::new(r"(?x)\Aold\ mode\ [0-7]{6}\r?\nnew\ mode\ [0-7]{6}\r?\n?\z")?;
58 if diff_mode_only.is_match(front_matter) || diff_binary_file.is_match(front_matter) {
59 return Ok(None);
60 }
61 Err(DiffError::MalformedDiffError(front_matter.to_string()))
62}
63
64static HUNK_INFO_PATTERN: &str = r"(?m)@@\s\-\d+,?\d*\s\+(\d+),?(\d*)\s@@";
66
67fn parse_patch(patch: &str) -> Result<(Vec<u32>, Vec<Range<u32>>), DiffError> {
74 let mut diff_hunks = Vec::new();
75 let mut additions = Vec::new();
76
77 let hunk_info = Regex::new(HUNK_INFO_PATTERN)?;
78 let hunk_headers = hunk_info.captures_iter(patch).collect::<Vec<_>>();
79 if !hunk_headers.is_empty() {
80 let hunks = hunk_info.split(patch).skip(1);
82 for (hunk, header) in hunks.zip(hunk_headers) {
83 let [start_line, end_range] = header.extract().1.map(|v| v.parse::<u32>().unwrap_or(1));
85 let mut line_numb_in_diff = start_line;
86 diff_hunks.push(start_line..start_line + end_range);
87 for (line_index, line) in hunk.split('\n').enumerate() {
88 if line.starts_with('+') {
89 additions.push(line_numb_in_diff);
90 }
91 if line_index > 0 && !line.starts_with('-') {
92 line_numb_in_diff += 1;
93 }
94 }
95 }
96 }
97 Ok((additions, diff_hunks))
98}
99
100pub fn parse_diff(
107 diff: &str,
108 file_filter: &FileFilter,
109 lines_changed_only: &LinesChangedOnly,
110) -> Result<HashMap<String, FileDiffLines>, DiffError> {
111 let mut results = HashMap::new();
112 let diff_file_delimiter = Regex::new(r"(?m)^diff \-\-git a/.*$")?;
113 let hunk_info = Regex::new(HUNK_INFO_PATTERN)?;
114
115 let file_diffs = diff_file_delimiter.split(diff);
116 for file_diff in file_diffs {
117 if file_diff.is_empty() || file_diff.starts_with("deleted file") {
118 continue;
119 }
120 let hunk_start = if let Some(first_hunk) = hunk_info.find(file_diff) {
121 first_hunk.start()
122 } else {
123 file_diff.len()
124 };
125 let front_matter = &file_diff[..hunk_start];
126 if let Some(file_name) = get_filename_from_front_matter(front_matter.trim_start())? {
127 let file_name = file_name.strip_prefix('/').unwrap_or(file_name);
128 if file_filter.is_qualified(Path::new(file_name)) {
129 let (added_lines, diff_hunks) = parse_patch(&file_diff[hunk_start..])?;
130 if lines_changed_only
131 .is_change_valid(!added_lines.is_empty(), !diff_hunks.is_empty())
132 {
133 results
134 .entry(file_name.to_string())
135 .or_insert_with(|| FileDiffLines::with_info(added_lines, diff_hunks));
136 }
137 }
138 }
139 }
140 Ok(results)
141}
142
143#[cfg(test)]
145mod test {
146 #![allow(clippy::unwrap_used)]
147
148 use super::parse_diff;
149 use crate::{FileFilter, LinesChangedOnly, error::DiffError};
150
151 const BAD_DIFF: &str = r#"{"message":"Resource not accessible by integration"}"#;
152
153 #[test]
154 fn bad_diff() {
155 let files = parse_diff(
156 BAD_DIFF,
157 &FileFilter::new(&[], &["rs"], None),
158 &LinesChangedOnly::Diff,
159 );
160 let e = files.unwrap_err();
161 assert!(matches!(e, DiffError::MalformedDiffError(_)));
162 assert!(e.to_string().ends_with(BAD_DIFF));
163 }
164
165 const RENAMED_DIFF: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c
166similarity index 100%
167rename from /tests/demo/some source.cpp
168rename to /tests/demo/some source.c
169diff --git a/some picture.png b/some picture.png
170new file mode 100644
171Binary files /dev/null and b/some picture.png differ
172"#;
173
174 #[test]
175 fn parse_renamed_diff() {
176 let files = parse_diff(
177 RENAMED_DIFF,
178 &FileFilter::new(&[], &["c"], None),
179 &LinesChangedOnly::Off,
180 )
181 .unwrap();
182 let git_file = files.get("tests/demo/some source.c").unwrap();
183 assert!(git_file.added_lines.is_empty());
184 assert!(git_file.diff_hunks.is_empty());
185 }
186
187 #[test]
188 fn parse_renamed_only_diff() {
189 let files = parse_diff(
190 RENAMED_DIFF,
191 &FileFilter::new(&[], &["c"], None),
192 &LinesChangedOnly::Diff,
193 )
194 .unwrap();
195 assert!(files.is_empty());
196 }
197
198 const RENAMED_DIFF_WITH_CHANGES: &str = r#"diff --git a/tests/demo/some source.cpp b/tests/demo/some source.c
199similarity index 99%
200rename from /tests/demo/some source.cpp
201rename to /tests/demo/some source.c
202@@ -3,7 +3,7 @@
203\n \n \n-#include "math.h"
204+#include <math.h>\n \n \n \n"#;
205
206 #[test]
207 fn parse_renamed_diff_with_patch() {
208 let files = parse_diff(
209 &String::from_iter([RENAMED_DIFF_WITH_CHANGES, TERSE_HEADERS]),
210 &FileFilter::new(&["src/*"], &["c", "cpp"], None),
213 &LinesChangedOnly::On,
214 )
215 .unwrap();
216 eprintln!("files: {files:#?}");
217 let git_file = files.get("tests/demo/some source.c").unwrap();
218 assert!(!git_file.is_line_in_diff(&1));
219 assert!(git_file.is_line_in_diff(&4));
220 }
221
222 const TYPICAL_DIFF: &str = "diff --git a/path/for/Some file.cpp b/path/to/Some file.cpp\n\
223 --- a/path/for/Some file.cpp\n\
224 +++ b/path/to/Some file.cpp\n\
225 @@ -3,7 +3,7 @@\n \n \n \n\
226 -#include <some_lib/render/animation.hpp>\n\
227 +#include <some_lib/render/animations.hpp>\n \n \n \n";
228
229 #[test]
230 fn parse_typical_diff() {
231 let files = parse_diff(
232 TYPICAL_DIFF,
233 &FileFilter::new(&[], &["cpp"], None),
234 &LinesChangedOnly::On,
235 )
236 .unwrap();
237 assert!(!files.is_empty());
238 }
239
240 const IGNORED_DIFF: &str = "diff --git a/some picture.png b/some picture.png\n\
241 new file mode 100644\n\
242 Binary files /dev/null and b/some picture.png differ\n\
243 diff --git a/script.sh b/script.sh\n\
244 old mode 100644\n\
245 new mode 100755\n";
246
247 #[test]
248 fn parse_ignored_diff() {
249 let files = parse_diff(
250 IGNORED_DIFF,
251 &FileFilter::new(&[], &["png"], None),
252 &LinesChangedOnly::Diff,
253 )
254 .unwrap();
255 assert!(files.is_empty());
256 }
257
258 const TERSE_HEADERS: &str = r#"diff --git a/src/demo.cpp b/src/demo.cpp
259--- a/src/demo.cpp
260+++ b/src/demo.cpp
261@@ -3 +3 @@
262-#include <stdio.h>
263+#include "stdio.h"
264@@ -4,0 +5,2 @@
265+auto main() -> int
266+{
267@@ -18 +17,2 @@ int main(){
268- return 0;}
269+ return 0;
270+}"#;
271
272 #[test]
273 fn terse_hunk_header() {
274 let file_filter = FileFilter::new(&[], &["cpp"], None);
275 let files = parse_diff(TERSE_HEADERS, &file_filter, &LinesChangedOnly::Diff).unwrap();
276 let file_diff = files.get("src/demo.cpp").unwrap();
277 assert_eq!(file_diff.diff_hunks, vec![3..4, 5..7, 17..19]);
278 }
279}