rust_diff_analyzer/analysis/
mapper.rs1use std::{collections::HashMap, path::Path};
5
6use masterror::AppError;
7
8use super::extractor::extract_semantic_units_from_str;
9use crate::{
10 classifier::classify_unit,
11 config::Config,
12 git::FileDiff,
13 types::{AnalysisScope, Change, ExclusionReason, SemanticUnit},
14};
15
16pub struct MapResult {
18 pub changes: Vec<Change>,
20 pub scope: AnalysisScope,
22}
23
24pub fn map_changes<F>(
53 diffs: &[FileDiff],
54 config: &Config,
55 file_reader: F,
56) -> Result<MapResult, AppError>
57where
58 F: Fn(&Path) -> Result<String, std::io::Error>,
59{
60 let mut changes = Vec::new();
61 let mut scope = AnalysisScope::new();
62
63 scope.set_patterns(config.classification.ignore_paths.clone());
64
65 for diff in diffs {
66 if !diff.is_rust_file() {
67 scope.add_skipped(diff.path.clone(), ExclusionReason::NonRust);
68 continue;
69 }
70
71 if diff.is_deleted {
72 scope.add_skipped(diff.path.clone(), ExclusionReason::Deleted);
73 continue;
74 }
75
76 if let Some(pattern) = config.matched_ignore_pattern(&diff.path) {
77 scope.add_skipped(
78 diff.path.clone(),
79 ExclusionReason::IgnorePattern(pattern.to_string()),
80 );
81 continue;
82 }
83
84 let content = match file_reader(&diff.path) {
85 Ok(content) => content,
86 Err(e) => {
87 scope.add_skipped(diff.path.clone(), ExclusionReason::ReadError(e.to_string()));
88 continue;
89 }
90 };
91
92 let units = match extract_semantic_units_from_str(&content, &diff.path) {
93 Ok(units) => units,
94 Err(e) => {
95 scope.add_skipped(
96 diff.path.clone(),
97 ExclusionReason::ParseError(e.to_string()),
98 );
99 continue;
100 }
101 };
102
103 scope.add_analyzed(diff.path.clone());
104
105 let added_lines = diff.all_added_lines();
106 let removed_positions = diff.all_removed_positions_in_new();
107
108 let mut unit_changes: HashMap<usize, (usize, usize)> = HashMap::new();
109
110 for line in &added_lines {
111 if let Some(index) = find_containing_unit_index(&units, *line) {
112 let entry = unit_changes.entry(index).or_insert((0, 0));
113 entry.0 += 1;
114 }
115 }
116
117 for line in &removed_positions {
118 if let Some(index) = find_containing_unit_index(&units, *line) {
119 let entry = unit_changes.entry(index).or_insert((0, 0));
120 entry.1 += 1;
121 }
122 }
123
124 for (index, unit) in units.iter().enumerate() {
125 if let Some((added, removed)) = unit_changes.get(&index) {
126 let classification = classify_unit(unit, &diff.path, config);
127
128 changes.push(Change::new(
129 diff.path.clone(),
130 unit.clone(),
131 classification,
132 *added,
133 *removed,
134 ));
135 }
136 }
137 }
138
139 Ok(MapResult { changes, scope })
140}
141
142fn find_containing_unit_index(units: &[SemanticUnit], line: usize) -> Option<usize> {
143 let mut best_match: Option<usize> = None;
144
145 for (index, unit) in units.iter().enumerate() {
146 if unit.span.contains(line) {
147 match best_match {
148 None => best_match = Some(index),
149 Some(current) => {
150 if unit.span.len() < units[current].span.len() {
151 best_match = Some(index);
152 }
153 }
154 }
155 }
156 }
157
158 best_match
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use crate::types::{LineSpan, SemanticUnitKind, Visibility};
165
166 #[test]
167 fn test_find_containing_unit_index() {
168 let units = vec![
169 SemanticUnit::new(
170 SemanticUnitKind::Module,
171 "module".to_string(),
172 Visibility::Private,
173 LineSpan::new(1, 100),
174 vec![],
175 ),
176 SemanticUnit::new(
177 SemanticUnitKind::Function,
178 "func".to_string(),
179 Visibility::Public,
180 LineSpan::new(10, 20),
181 vec![],
182 ),
183 ];
184
185 assert_eq!(find_containing_unit_index(&units, 15), Some(1));
186 assert_eq!(find_containing_unit_index(&units, 50), Some(0));
187 assert_eq!(find_containing_unit_index(&units, 200), None);
188 }
189
190 #[test]
191 fn test_removed_lines_attributed_in_new_coordinates() {
192 use std::path::PathBuf;
193
194 use crate::{
195 config::Config,
196 git::{FileDiff, Hunk, HunkLine},
197 };
198
199 let content = "\
200fn first() {
201 1;
202}
203fn second() {
204 2;
205 3;
206}
207";
208
209 let mut hunk_top = Hunk::new(1, 10, 1, 0);
210 for old in 1..=10 {
211 hunk_top
212 .lines
213 .push(HunkLine::removed(old, format!("old prefix {}", old)));
214 }
215
216 let mut hunk_inner = Hunk::new(14, 4, 4, 3);
217 hunk_inner
218 .lines
219 .push(HunkLine::context(14, 4, "fn second() {".to_string()));
220 hunk_inner
221 .lines
222 .push(HunkLine::context(15, 5, " 2;".to_string()));
223 hunk_inner
224 .lines
225 .push(HunkLine::removed(16, " gone();".to_string()));
226 hunk_inner
227 .lines
228 .push(HunkLine::context(17, 6, " 3;".to_string()));
229
230 let mut diff = FileDiff::new(PathBuf::from("src/prod.rs"));
231 diff.hunks = vec![hunk_top, hunk_inner];
232
233 let config = Config::default();
234 let result =
235 map_changes(&[diff], &config, |_| Ok(content.to_string())).expect("map should work");
236
237 let second = result
238 .changes
239 .iter()
240 .find(|c| c.unit.name == "second")
241 .expect("removal inside second must be attributed to second");
242 assert_eq!(second.lines_removed, 1);
243
244 let first = result
245 .changes
246 .iter()
247 .find(|c| c.unit.name == "first")
248 .expect("prefix removals must be attributed to the adjacent unit");
249 assert_eq!(first.lines_removed, 10);
250 }
251
252 #[test]
253 fn test_struct_and_impl_with_same_name_not_double_counted() {
254 use std::path::PathBuf;
255
256 use crate::{
257 config::Config,
258 git::{FileDiff, Hunk, HunkLine},
259 };
260
261 let content = "\
262pub struct Foo {
263 pub value: u32,
264}
265
266impl Foo {
267 pub fn get(&self) -> u32 {
268 self.value
269 }
270}
271";
272
273 let mut hunk = Hunk::new(1, 2, 1, 3);
274 hunk.lines
275 .push(HunkLine::context(1, 1, "pub struct Foo {".to_string()));
276 hunk.lines
277 .push(HunkLine::added(2, " pub value: u32,".to_string()));
278 hunk.lines.push(HunkLine::context(2, 3, "}".to_string()));
279
280 let mut diff = FileDiff::new(PathBuf::from("src/prod.rs"));
281 diff.hunks = vec![hunk];
282
283 let config = Config::default();
284 let result =
285 map_changes(&[diff], &config, |_| Ok(content.to_string())).expect("map should work");
286
287 assert_eq!(result.changes.len(), 1);
288 assert_eq!(result.changes[0].unit.name, "Foo");
289 assert_eq!(result.changes[0].lines_added, 1);
290 }
291
292 #[test]
293 fn test_unreadable_file_is_skipped_not_fatal() {
294 use std::{io, path::PathBuf};
295
296 use crate::{config::Config, git::FileDiff};
297
298 let diff = FileDiff::new(PathBuf::from("src/missing.rs"));
299 let config = Config::default();
300
301 let result = map_changes(&[diff], &config, |_| {
302 Err(io::Error::new(io::ErrorKind::NotFound, "no such file"))
303 })
304 .expect("read failure must not abort analysis");
305
306 assert!(result.changes.is_empty());
307 assert!(result.scope.analyzed_files.is_empty());
308 assert_eq!(result.scope.error_count(), 1);
309 assert!(matches!(
310 result.scope.skipped_files[0].reason,
311 ExclusionReason::ReadError(_)
312 ));
313 }
314
315 #[test]
316 fn test_unparsable_file_is_skipped_not_fatal() {
317 use std::path::PathBuf;
318
319 use crate::{config::Config, git::FileDiff};
320
321 let diff = FileDiff::new(PathBuf::from("src/broken.rs"));
322 let config = Config::default();
323
324 let result = map_changes(&[diff], &config, |_| Ok("fn broken( {{{".to_string()))
325 .expect("parse failure must not abort analysis");
326
327 assert!(result.changes.is_empty());
328 assert_eq!(result.scope.error_count(), 1);
329 assert!(matches!(
330 result.scope.skipped_files[0].reason,
331 ExclusionReason::ParseError(_)
332 ));
333 }
334
335 #[test]
336 fn test_deleted_file_is_skipped_without_reading() {
337 use std::{io, path::PathBuf};
338
339 use crate::{config::Config, git::FileDiff};
340
341 let mut diff = FileDiff::new(PathBuf::from("src/gone.rs"));
342 diff.is_deleted = true;
343 let config = Config::default();
344
345 let result = map_changes(&[diff], &config, |path| {
346 Err(io::Error::new(
347 io::ErrorKind::NotFound,
348 format!("must not be read: {}", path.display()),
349 ))
350 })
351 .expect("deleted file must not abort analysis");
352
353 assert!(result.changes.is_empty());
354 assert_eq!(result.scope.skipped_files.len(), 1);
355 assert_eq!(
356 result.scope.skipped_files[0].reason,
357 ExclusionReason::Deleted
358 );
359 }
360}