git_iris/changes/
change_analyzer.rs

1use super::models::{ChangeMetrics, ChangelogType};
2use crate::context::{ChangeType, RecentCommit};
3use crate::git::GitRepo;
4use anyhow::Result;
5use git2::{Diff, Oid};
6use regex::Regex;
7use std::sync::Arc;
8
9/// Represents the analyzed changes for a single commit
10#[derive(Debug, Clone)]
11pub struct AnalyzedChange {
12    pub commit_hash: String,
13    pub commit_message: String,
14    pub author: String,
15    pub file_changes: Vec<FileChange>,
16    pub metrics: ChangeMetrics,
17    pub impact_score: f32,
18    pub change_type: ChangelogType,
19    pub is_breaking_change: bool,
20    pub associated_issues: Vec<String>,
21    pub pull_request: Option<String>,
22}
23
24/// Represents changes to a single file
25#[derive(Debug, Clone)]
26pub struct FileChange {
27    pub old_path: String,
28    pub new_path: String,
29    pub change_type: ChangeType,
30    pub analysis: Vec<String>,
31}
32
33/// Analyzer for processing Git commits and generating detailed change information
34pub struct ChangeAnalyzer {
35    git_repo: Arc<GitRepo>,
36}
37
38impl ChangeAnalyzer {
39    /// Create a new `ChangeAnalyzer` instance
40    pub fn new(git_repo: Arc<GitRepo>) -> Result<Self> {
41        Ok(Self { git_repo })
42    }
43
44    /// Analyze commits between two Git references
45    pub fn analyze_commits(&self, from: &str, to: &str) -> Result<Vec<AnalyzedChange>> {
46        self.git_repo
47            .get_commits_between_with_callback(from, to, |commit| self.analyze_commit(commit))
48    }
49
50    /// Analyze changes between two Git references and return the analyzed changes along with total metrics
51    pub fn analyze_changes(
52        &self,
53        from: &str,
54        to: &str,
55    ) -> Result<(Vec<AnalyzedChange>, ChangeMetrics)> {
56        let analyzed_changes = self.analyze_commits(from, to)?;
57        let total_metrics = self.calculate_total_metrics(&analyzed_changes);
58        Ok((analyzed_changes, total_metrics))
59    }
60
61    /// Analyze a single commit
62    fn analyze_commit(&self, commit: &RecentCommit) -> Result<AnalyzedChange> {
63        let repo = self.git_repo.open_repo()?;
64        let commit_obj = repo.find_commit(Oid::from_str(&commit.hash)?)?;
65
66        let parent_tree = if commit_obj.parent_count() > 0 {
67            Some(commit_obj.parent(0)?.tree()?)
68        } else {
69            None
70        };
71
72        let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit_obj.tree()?), None)?;
73
74        let file_changes = Self::analyze_file_changes(&diff)?;
75        let metrics = Self::calculate_metrics(&diff)?;
76        let change_type = Self::classify_change(&commit.message, &file_changes);
77        let is_breaking_change = Self::detect_breaking_change(&commit.message, &file_changes);
78        let associated_issues = Self::extract_associated_issues(&commit.message);
79        let pull_request = Self::extract_pull_request(&commit.message);
80        let impact_score =
81            Self::calculate_impact_score(&metrics, &file_changes, is_breaking_change);
82
83        Ok(AnalyzedChange {
84            commit_hash: commit.hash.clone(),
85            commit_message: commit.message.clone(),
86            author: commit.author.clone(),
87            file_changes,
88            metrics,
89            impact_score,
90            change_type,
91            is_breaking_change,
92            associated_issues,
93            pull_request,
94        })
95    }
96
97    /// Analyze changes for each file in the commit
98    fn analyze_file_changes(diff: &Diff) -> Result<Vec<FileChange>> {
99        let mut file_changes = Vec::new();
100
101        diff.foreach(
102            &mut |delta, _| {
103                let old_file = delta.old_file();
104                let new_file = delta.new_file();
105                let change_type = match delta.status() {
106                    git2::Delta::Added => ChangeType::Added,
107                    git2::Delta::Deleted => ChangeType::Deleted,
108                    _ => ChangeType::Modified,
109                };
110
111                let file_path = new_file.path().map_or_else(
112                    || {
113                        old_file
114                            .path()
115                            .map(|p| p.to_string_lossy().into_owned())
116                            .unwrap_or_default()
117                    },
118                    |p| p.to_string_lossy().into_owned(),
119                );
120
121                // Perform file-specific analysis based on file type
122                let mut analysis = Vec::new();
123
124                // Determine file type and add relevant analysis
125                if let Some(extension) = std::path::Path::new(&file_path).extension() {
126                    if let Some(ext_str) = extension.to_str() {
127                        match ext_str.to_lowercase().as_str() {
128                            "rs" => analysis.push("Rust source code changes".to_string()),
129                            "js" | "ts" => {
130                                analysis.push("JavaScript/TypeScript changes".to_string());
131                            }
132                            "py" => analysis.push("Python code changes".to_string()),
133                            "java" => analysis.push("Java code changes".to_string()),
134                            "c" | "cpp" | "h" => analysis.push("C/C++ code changes".to_string()),
135                            "md" => analysis.push("Documentation changes".to_string()),
136                            "json" | "yml" | "yaml" | "toml" => {
137                                analysis.push("Configuration changes".to_string());
138                            }
139                            _ => {}
140                        }
141                    }
142                }
143
144                // Add analysis based on change type
145                match change_type {
146                    ChangeType::Added => analysis.push("New file added".to_string()),
147                    ChangeType::Deleted => analysis.push("File removed".to_string()),
148                    ChangeType::Modified => {
149                        if file_path.contains("test") || file_path.contains("spec") {
150                            analysis.push("Test modifications".to_string());
151                        } else if file_path.contains("README") || file_path.contains("docs/") {
152                            analysis.push("Documentation updates".to_string());
153                        }
154                    }
155                }
156
157                let file_change = FileChange {
158                    old_path: old_file
159                        .path()
160                        .map(|p| p.to_string_lossy().into_owned())
161                        .unwrap_or_default(),
162                    new_path: new_file
163                        .path()
164                        .map(|p| p.to_string_lossy().into_owned())
165                        .unwrap_or_default(),
166                    change_type,
167                    analysis,
168                };
169
170                file_changes.push(file_change);
171                true
172            },
173            None,
174            None,
175            None,
176        )?;
177
178        Ok(file_changes)
179    }
180
181    /// Calculate metrics for the commit
182    fn calculate_metrics(diff: &Diff) -> Result<ChangeMetrics> {
183        let stats = diff.stats()?;
184        Ok(ChangeMetrics {
185            total_commits: 1,
186            files_changed: stats.files_changed(),
187            insertions: stats.insertions(),
188            deletions: stats.deletions(),
189            total_lines_changed: stats.insertions() + stats.deletions(),
190        })
191    }
192
193    /// Classify the type of change based on commit message and file changes
194    fn classify_change(commit_message: &str, file_changes: &[FileChange]) -> ChangelogType {
195        let message_lower = commit_message.to_lowercase();
196
197        // First, check the commit message
198        if message_lower.contains("add") || message_lower.contains("new") {
199            return ChangelogType::Added;
200        } else if message_lower.contains("deprecat") {
201            return ChangelogType::Deprecated;
202        } else if message_lower.contains("remov") || message_lower.contains("delet") {
203            return ChangelogType::Removed;
204        } else if message_lower.contains("fix") || message_lower.contains("bug") {
205            return ChangelogType::Fixed;
206        } else if message_lower.contains("secur") || message_lower.contains("vulnerab") {
207            return ChangelogType::Security;
208        }
209
210        // If the commit message doesn't give us a clear indication, check the file changes
211        let has_additions = file_changes
212            .iter()
213            .any(|fc| fc.change_type == ChangeType::Added);
214        let has_deletions = file_changes
215            .iter()
216            .any(|fc| fc.change_type == ChangeType::Deleted);
217
218        if has_additions && !has_deletions {
219            ChangelogType::Added
220        } else if has_deletions && !has_additions {
221            ChangelogType::Removed
222        } else {
223            ChangelogType::Changed
224        }
225    }
226
227    /// Detect if the change is a breaking change
228    fn detect_breaking_change(commit_message: &str, file_changes: &[FileChange]) -> bool {
229        let message_lower = commit_message.to_lowercase();
230        if message_lower.contains("breaking change")
231            || message_lower.contains("breaking-change")
232            || message_lower.contains("major version")
233        {
234            return true;
235        }
236
237        // Check file changes for potential breaking changes
238        file_changes.iter().any(|fc| {
239            fc.analysis.iter().any(|analysis| {
240                analysis.to_lowercase().contains("breaking change")
241                    || analysis.to_lowercase().contains("api change")
242                    || analysis.to_lowercase().contains("incompatible")
243            })
244        })
245    }
246
247    /// Extract associated issue numbers from the commit message
248    fn extract_associated_issues(commit_message: &str) -> Vec<String> {
249        let re = Regex::new(r"(?:#|GH-)(\d+)")
250            .expect("Failed to compile issue number regex pattern - this is a bug");
251        re.captures_iter(commit_message)
252            .map(|cap| format!("#{}", &cap[1]))
253            .collect()
254    }
255
256    /// Extract pull request number from the commit message
257    fn extract_pull_request(commit_message: &str) -> Option<String> {
258        let re = Regex::new(r"(?i)(?:pull request|PR)\s*#?(\d+)")
259            .expect("Failed to compile pull request regex pattern - this is a bug");
260        re.captures(commit_message)
261            .map(|cap| format!("PR #{}", &cap[1]))
262    }
263
264    /// Calculate the impact score of the change
265    #[allow(clippy::cast_precision_loss)]
266    #[allow(clippy::as_conversions)]
267    fn calculate_impact_score(
268        metrics: &ChangeMetrics,
269        file_changes: &[FileChange],
270        is_breaking_change: bool,
271    ) -> f32 {
272        let base_score = (metrics.total_lines_changed as f32) / 100.0;
273        let file_score = file_changes.len() as f32 / 10.0;
274        let breaking_change_score = if is_breaking_change { 5.0 } else { 0.0 };
275
276        base_score + file_score + breaking_change_score
277    }
278
279    /// Calculate total metrics for a set of analyzed changes
280    pub fn calculate_total_metrics(&self, changes: &[AnalyzedChange]) -> ChangeMetrics {
281        changes.iter().fold(
282            ChangeMetrics {
283                total_commits: changes.len(),
284                files_changed: 0,
285                insertions: 0,
286                deletions: 0,
287                total_lines_changed: 0,
288            },
289            |mut acc, change| {
290                acc.files_changed += change.metrics.files_changed;
291                acc.insertions += change.metrics.insertions;
292                acc.deletions += change.metrics.deletions;
293                acc.total_lines_changed += change.metrics.total_lines_changed;
294                acc
295            },
296        )
297    }
298}