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    error::FileReadError,
13    git::FileDiff,
14    types::{AnalysisScope, Change, ExclusionReason, SemanticUnit},
15};
16
17/// Result of mapping changes including scope information
18pub struct MapResult {
19    /// List of changes
20    pub changes: Vec<Change>,
21    /// Analysis scope
22    pub scope: AnalysisScope,
23}
24
25/// Maps diff changes to semantic units
26///
27/// # Arguments
28///
29/// * `diffs` - Vector of file diffs
30/// * `config` - Configuration
31/// * `file_reader` - Function to read file contents
32///
33/// # Returns
34///
35/// MapResult with changes and scope or error
36///
37/// # Errors
38///
39/// Returns error if file reading or parsing fails
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 config.should_ignore(&diff.path) {
72            let pattern = config
73                .classification
74                .ignore_paths
75                .iter()
76                .find(|p| diff.path.to_string_lossy().contains(p.as_str()))
77                .cloned()
78                .unwrap_or_default();
79            scope.add_skipped(diff.path.clone(), ExclusionReason::IgnorePattern(pattern));
80            continue;
81        }
82
83        scope.add_analyzed(diff.path.clone());
84
85        let content = file_reader(&diff.path)
86            .map_err(|e| AppError::from(FileReadError::new(diff.path.clone(), e)))?;
87
88        let units = extract_semantic_units_from_str(&content, &diff.path)?;
89
90        let added_lines = diff.all_added_lines();
91        let removed_lines = diff.all_removed_lines();
92
93        let mut unit_changes: HashMap<String, (usize, usize)> = HashMap::new();
94
95        for line in &added_lines {
96            if let Some(unit) = find_containing_unit(&units, *line) {
97                let entry = unit_changes.entry(unit.qualified_name()).or_insert((0, 0));
98                entry.0 += 1;
99            }
100        }
101
102        for line in &removed_lines {
103            if let Some(unit) = find_containing_unit(&units, *line) {
104                let entry = unit_changes.entry(unit.qualified_name()).or_insert((0, 0));
105                entry.1 += 1;
106            }
107        }
108
109        for unit in &units {
110            if let Some((added, removed)) = unit_changes.get(&unit.qualified_name()) {
111                let classification = classify_unit(unit, &diff.path, config);
112
113                changes.push(Change::new(
114                    diff.path.clone(),
115                    unit.clone(),
116                    classification,
117                    *added,
118                    *removed,
119                ));
120            }
121        }
122    }
123
124    Ok(MapResult { changes, scope })
125}
126
127fn find_containing_unit(units: &[SemanticUnit], line: usize) -> Option<&SemanticUnit> {
128    let mut best_match: Option<&SemanticUnit> = None;
129
130    for unit in units {
131        if unit.span.contains(line) {
132            match best_match {
133                None => best_match = Some(unit),
134                Some(current) => {
135                    if unit.span.len() < current.span.len() {
136                        best_match = Some(unit);
137                    }
138                }
139            }
140        }
141    }
142
143    best_match
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use crate::types::{LineSpan, SemanticUnitKind, Visibility};
150
151    #[test]
152    fn test_find_containing_unit() {
153        let units = vec![
154            SemanticUnit::new(
155                SemanticUnitKind::Module,
156                "module".to_string(),
157                Visibility::Private,
158                LineSpan::new(1, 100),
159                vec![],
160            ),
161            SemanticUnit::new(
162                SemanticUnitKind::Function,
163                "func".to_string(),
164                Visibility::Public,
165                LineSpan::new(10, 20),
166                vec![],
167            ),
168        ];
169
170        let result = find_containing_unit(&units, 15);
171        assert!(result.is_some());
172        assert_eq!(result.expect("should find unit").name, "func");
173
174        let result = find_containing_unit(&units, 50);
175        assert!(result.is_some());
176        assert_eq!(result.expect("should find unit").name, "module");
177
178        let result = find_containing_unit(&units, 200);
179        assert!(result.is_none());
180    }
181}