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_change = FileChange {
112                    old_path: old_file
113                        .path()
114                        .map(|p| p.to_string_lossy().into_owned())
115                        .unwrap_or_default(),
116                    new_path: new_file
117                        .path()
118                        .map(|p| p.to_string_lossy().into_owned())
119                        .unwrap_or_default(),
120                    change_type,
121                    analysis: vec![], // TODO: Implement file-specific analysis if needed
122                };
123
124                file_changes.push(file_change);
125                true
126            },
127            None,
128            None,
129            None,
130        )?;
131
132        Ok(file_changes)
133    }
134
135    /// Calculate metrics for the commit
136    fn calculate_metrics(diff: &Diff) -> Result<ChangeMetrics> {
137        let stats = diff.stats()?;
138        Ok(ChangeMetrics {
139            total_commits: 1,
140            files_changed: stats.files_changed(),
141            insertions: stats.insertions(),
142            deletions: stats.deletions(),
143            total_lines_changed: stats.insertions() + stats.deletions(),
144        })
145    }
146
147    /// Classify the type of change based on commit message and file changes
148    fn classify_change(commit_message: &str, file_changes: &[FileChange]) -> ChangelogType {
149        let message_lower = commit_message.to_lowercase();
150
151        // First, check the commit message
152        if message_lower.contains("add") || message_lower.contains("new") {
153            return ChangelogType::Added;
154        } else if message_lower.contains("deprecat") {
155            return ChangelogType::Deprecated;
156        } else if message_lower.contains("remov") || message_lower.contains("delet") {
157            return ChangelogType::Removed;
158        } else if message_lower.contains("fix") || message_lower.contains("bug") {
159            return ChangelogType::Fixed;
160        } else if message_lower.contains("secur") || message_lower.contains("vulnerab") {
161            return ChangelogType::Security;
162        }
163
164        // If the commit message doesn't give us a clear indication, check the file changes
165        let has_additions = file_changes
166            .iter()
167            .any(|fc| fc.change_type == ChangeType::Added);
168        let has_deletions = file_changes
169            .iter()
170            .any(|fc| fc.change_type == ChangeType::Deleted);
171
172        if has_additions && !has_deletions {
173            ChangelogType::Added
174        } else if has_deletions && !has_additions {
175            ChangelogType::Removed
176        } else {
177            ChangelogType::Changed
178        }
179    }
180
181    /// Detect if the change is a breaking change
182    fn detect_breaking_change(commit_message: &str, file_changes: &[FileChange]) -> bool {
183        let message_lower = commit_message.to_lowercase();
184        if message_lower.contains("breaking change")
185            || message_lower.contains("breaking-change")
186            || message_lower.contains("major version")
187        {
188            return true;
189        }
190
191        // Check file changes for potential breaking changes
192        file_changes.iter().any(|fc| {
193            fc.analysis.iter().any(|analysis| {
194                analysis.to_lowercase().contains("breaking change")
195                    || analysis.to_lowercase().contains("api change")
196                    || analysis.to_lowercase().contains("incompatible")
197            })
198        })
199    }
200
201    /// Extract associated issue numbers from the commit message
202    fn extract_associated_issues(commit_message: &str) -> Vec<String> {
203        let re = Regex::new(r"(?:#|GH-)(\d+)")
204            .expect("Failed to compile issue number regex pattern - this is a bug");
205        re.captures_iter(commit_message)
206            .map(|cap| format!("#{}", &cap[1]))
207            .collect()
208    }
209
210    /// Extract pull request number from the commit message
211    fn extract_pull_request(commit_message: &str) -> Option<String> {
212        let re = Regex::new(r"(?i)(?:pull request|PR)\s*#?(\d+)")
213            .expect("Failed to compile pull request regex pattern - this is a bug");
214        re.captures(commit_message)
215            .map(|cap| format!("PR #{}", &cap[1]))
216    }
217
218    /// Calculate the impact score of the change
219    #[allow(clippy::cast_precision_loss)]
220    #[allow(clippy::as_conversions)]
221    fn calculate_impact_score(
222        metrics: &ChangeMetrics,
223        file_changes: &[FileChange],
224        is_breaking_change: bool,
225    ) -> f32 {
226        let base_score = (metrics.total_lines_changed as f32) / 100.0;
227        let file_score = file_changes.len() as f32 / 10.0;
228        let breaking_change_score = if is_breaking_change { 5.0 } else { 0.0 };
229
230        base_score + file_score + breaking_change_score
231    }
232
233    /// Calculate total metrics for a set of analyzed changes
234    pub fn calculate_total_metrics(&self, changes: &[AnalyzedChange]) -> ChangeMetrics {
235        changes.iter().fold(
236            ChangeMetrics {
237                total_commits: changes.len(),
238                files_changed: 0,
239                insertions: 0,
240                deletions: 0,
241                total_lines_changed: 0,
242            },
243            |mut acc, change| {
244                acc.files_changed += change.metrics.files_changed;
245                acc.insertions += change.metrics.insertions;
246                acc.deletions += change.metrics.deletions;
247                acc.total_lines_changed += change.metrics.total_lines_changed;
248                acc
249            },
250        )
251    }
252}