1use anyhow::{Context, Result};
4use chrono::{DateTime, FixedOffset};
5use git2::{Commit, Repository};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CommitInfo {
11 pub hash: String,
13 pub author: String,
15 pub date: DateTime<FixedOffset>,
17 pub original_message: String,
19 pub in_main_branches: Vec<String>,
21 pub analysis: CommitAnalysis,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct CommitAnalysis {
28 pub detected_type: String,
30 pub detected_scope: String,
32 pub proposed_message: String,
34 pub file_changes: FileChanges,
36 pub diff_summary: String,
38}
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct FileChanges {
43 pub total_files: usize,
45 pub files_added: usize,
47 pub files_deleted: usize,
49 pub file_list: Vec<FileChange>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct FileChange {
56 pub status: String,
58 pub file: String,
60}
61
62impl CommitInfo {
63 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
65 let hash = commit.id().to_string();
66
67 let author = format!(
68 "{} <{}>",
69 commit.author().name().unwrap_or("Unknown"),
70 commit.author().email().unwrap_or("unknown@example.com")
71 );
72
73 let timestamp = commit.author().when();
74 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
75 .context("Invalid commit timestamp")?
76 .with_timezone(
77 &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
78 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
79 );
80
81 let original_message = commit.message().unwrap_or("").to_string();
82
83 let in_main_branches = Vec::new();
85
86 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
88
89 Ok(Self {
90 hash,
91 author,
92 date,
93 original_message,
94 in_main_branches,
95 analysis,
96 })
97 }
98}
99
100impl CommitAnalysis {
101 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
103 let file_changes = Self::analyze_file_changes(repo, commit)?;
105
106 let detected_type = Self::detect_commit_type(commit, &file_changes);
108
109 let detected_scope = Self::detect_scope(&file_changes);
111
112 let proposed_message =
114 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
115
116 let diff_summary = Self::get_diff_summary(repo, commit)?;
118
119 Ok(Self {
120 detected_type,
121 detected_scope,
122 proposed_message,
123 file_changes,
124 diff_summary,
125 })
126 }
127
128 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
130 let mut file_list = Vec::new();
131 let mut files_added = 0;
132 let mut files_deleted = 0;
133
134 let commit_tree = commit.tree().context("Failed to get commit tree")?;
136
137 let parent_tree = if commit.parent_count() > 0 {
139 Some(
140 commit
141 .parent(0)
142 .context("Failed to get parent commit")?
143 .tree()
144 .context("Failed to get parent tree")?,
145 )
146 } else {
147 None
148 };
149
150 let diff = if let Some(parent_tree) = parent_tree {
152 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
153 .context("Failed to create diff")?
154 } else {
155 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
157 .context("Failed to create diff for initial commit")?
158 };
159
160 diff.foreach(
162 &mut |delta, _progress| {
163 let status = match delta.status() {
164 git2::Delta::Added => {
165 files_added += 1;
166 "A"
167 }
168 git2::Delta::Deleted => {
169 files_deleted += 1;
170 "D"
171 }
172 git2::Delta::Modified => "M",
173 git2::Delta::Renamed => "R",
174 git2::Delta::Copied => "C",
175 git2::Delta::Typechange => "T",
176 _ => "?",
177 };
178
179 if let Some(path) = delta.new_file().path() {
180 if let Some(path_str) = path.to_str() {
181 file_list.push(FileChange {
182 status: status.to_string(),
183 file: path_str.to_string(),
184 });
185 }
186 }
187
188 true
189 },
190 None,
191 None,
192 None,
193 )
194 .context("Failed to process diff")?;
195
196 let total_files = file_list.len();
197
198 Ok(FileChanges {
199 total_files,
200 files_added,
201 files_deleted,
202 file_list,
203 })
204 }
205
206 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
208 let message = commit.message().unwrap_or("");
209
210 if let Some(existing_type) = Self::extract_conventional_type(message) {
212 return existing_type;
213 }
214
215 let files: Vec<&str> = file_changes
217 .file_list
218 .iter()
219 .map(|f| f.file.as_str())
220 .collect();
221
222 if files
224 .iter()
225 .any(|f| f.contains("test") || f.contains("spec"))
226 {
227 "test".to_string()
228 } else if files
229 .iter()
230 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
231 {
232 "docs".to_string()
233 } else if files
234 .iter()
235 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
236 {
237 if file_changes.files_added > 0 {
238 "feat".to_string()
239 } else {
240 "chore".to_string()
241 }
242 } else if file_changes.files_added > 0
243 && files
244 .iter()
245 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
246 {
247 "feat".to_string()
248 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
249 "fix".to_string()
250 } else if file_changes.files_deleted > file_changes.files_added {
251 "refactor".to_string()
252 } else {
253 "chore".to_string()
254 }
255 }
256
257 fn extract_conventional_type(message: &str) -> Option<String> {
259 let first_line = message.lines().next().unwrap_or("");
260 if let Some(colon_pos) = first_line.find(':') {
261 let prefix = &first_line[..colon_pos];
262 if let Some(paren_pos) = prefix.find('(') {
263 let type_part = &prefix[..paren_pos];
264 if Self::is_valid_conventional_type(type_part) {
265 return Some(type_part.to_string());
266 }
267 } else if Self::is_valid_conventional_type(prefix) {
268 return Some(prefix.to_string());
269 }
270 }
271 None
272 }
273
274 fn is_valid_conventional_type(s: &str) -> bool {
276 matches!(
277 s,
278 "feat"
279 | "fix"
280 | "docs"
281 | "style"
282 | "refactor"
283 | "test"
284 | "chore"
285 | "build"
286 | "ci"
287 | "perf"
288 )
289 }
290
291 fn detect_scope(file_changes: &FileChanges) -> String {
293 let files: Vec<&str> = file_changes
294 .file_list
295 .iter()
296 .map(|f| f.file.as_str())
297 .collect();
298
299 if files.iter().any(|f| f.starts_with("src/cli/")) {
301 "cli".to_string()
302 } else if files.iter().any(|f| f.starts_with("src/git/")) {
303 "git".to_string()
304 } else if files.iter().any(|f| f.starts_with("src/data/")) {
305 "data".to_string()
306 } else if files.iter().any(|f| f.starts_with("tests/")) {
307 "test".to_string()
308 } else if files.iter().any(|f| f.starts_with("docs/")) {
309 "docs".to_string()
310 } else if files
311 .iter()
312 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
313 {
314 "deps".to_string()
315 } else {
316 "".to_string()
317 }
318 }
319
320 fn generate_proposed_message(
322 commit: &Commit,
323 commit_type: &str,
324 scope: &str,
325 file_changes: &FileChanges,
326 ) -> String {
327 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
328
329 if Self::extract_conventional_type(current_message).is_some() {
331 return current_message.to_string();
332 }
333
334 let description =
336 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
337 current_message.to_string()
338 } else {
339 Self::generate_description(commit_type, file_changes)
340 };
341
342 if scope.is_empty() {
344 format!("{}: {}", commit_type, description)
345 } else {
346 format!("{}({}): {}", commit_type, scope, description)
347 }
348 }
349
350 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
352 match commit_type {
353 "feat" => {
354 if file_changes.total_files == 1 {
355 format!("add {}", file_changes.file_list[0].file)
356 } else {
357 format!("add {} new features", file_changes.total_files)
358 }
359 }
360 "fix" => "resolve issues".to_string(),
361 "docs" => "update documentation".to_string(),
362 "test" => "add tests".to_string(),
363 "refactor" => "improve code structure".to_string(),
364 "chore" => "update project files".to_string(),
365 _ => "update project".to_string(),
366 }
367 }
368
369 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
371 let commit_tree = commit.tree().context("Failed to get commit tree")?;
372
373 let parent_tree = if commit.parent_count() > 0 {
374 Some(
375 commit
376 .parent(0)
377 .context("Failed to get parent commit")?
378 .tree()
379 .context("Failed to get parent tree")?,
380 )
381 } else {
382 None
383 };
384
385 let diff = if let Some(parent_tree) = parent_tree {
386 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
387 .context("Failed to create diff")?
388 } else {
389 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
390 .context("Failed to create diff for initial commit")?
391 };
392
393 let stats = diff.stats().context("Failed to get diff stats")?;
394
395 let mut summary = String::new();
396 for i in 0..stats.files_changed() {
397 if let Some(path) = diff
398 .get_delta(i)
399 .and_then(|d| d.new_file().path())
400 .and_then(|p| p.to_str())
401 {
402 let insertions = stats.insertions();
403 let deletions = stats.deletions();
404 summary.push_str(&format!(
405 " {} | {} +{} -{}\n",
406 path,
407 insertions + deletions,
408 insertions,
409 deletions
410 ));
411 }
412 }
413
414 Ok(summary)
415 }
416}