Skip to main content

rust_diff_analyzer/analysis/
mapper.rs

1// SPDX-FileCopyrightText: 2025 RAprogramm <andrey.rozanov.vl@gmail.com>
2// SPDX-License-Identifier: MIT
3
4use 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
16/// Result of mapping changes including scope information
17pub struct MapResult {
18    /// List of changes
19    pub changes: Vec<Change>,
20    /// Analysis scope
21    pub scope: AnalysisScope,
22}
23
24/// Maps diff changes to semantic units
25///
26/// # Arguments
27///
28/// * `diffs` - Vector of file diffs
29/// * `config` - Configuration
30/// * `file_reader` - Function to read file contents
31///
32/// # Returns
33///
34/// MapResult with changes and scope or error
35///
36/// # Errors
37///
38/// Currently never returns an error: files that fail to read or parse are
39/// recorded in the scope as skipped. The `Result` is kept for API stability.
40///
41/// # Examples
42///
43/// ```no_run
44/// use std::fs;
45///
46/// use rust_diff_analyzer::{analysis::map_changes, config::Config};
47///
48/// let diffs = vec![];
49/// let config = Config::default();
50/// let result = map_changes(&diffs, &config, |p| fs::read_to_string(p));
51/// ```
52pub 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}