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#[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#[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
33pub struct ChangeAnalyzer {
35 git_repo: Arc<GitRepo>,
36}
37
38impl ChangeAnalyzer {
39 pub fn new(git_repo: Arc<GitRepo>) -> Result<Self> {
41 Ok(Self { git_repo })
42 }
43
44 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 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 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 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 let mut analysis = Vec::new();
123
124 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 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 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 fn classify_change(commit_message: &str, file_changes: &[FileChange]) -> ChangelogType {
195 let message_lower = commit_message.to_lowercase();
196
197 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 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 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 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 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 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 #[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 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}