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 pub diff_content: String,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FileChanges {
45 pub total_files: usize,
47 pub files_added: usize,
49 pub files_deleted: usize,
51 pub file_list: Vec<FileChange>,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize)]
57pub struct FileChange {
58 pub status: String,
60 pub file: String,
62}
63
64impl CommitInfo {
65 pub fn from_git_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
67 let hash = commit.id().to_string();
68
69 let author = format!(
70 "{} <{}>",
71 commit.author().name().unwrap_or("Unknown"),
72 commit.author().email().unwrap_or("unknown@example.com")
73 );
74
75 let timestamp = commit.author().when();
76 let date = DateTime::from_timestamp(timestamp.seconds(), 0)
77 .context("Invalid commit timestamp")?
78 .with_timezone(
79 &FixedOffset::east_opt(timestamp.offset_minutes() * 60)
80 .unwrap_or_else(|| FixedOffset::east_opt(0).unwrap()),
81 );
82
83 let original_message = commit.message().unwrap_or("").to_string();
84
85 let in_main_branches = Vec::new();
87
88 let analysis = CommitAnalysis::analyze_commit(repo, commit)?;
90
91 Ok(Self {
92 hash,
93 author,
94 date,
95 original_message,
96 in_main_branches,
97 analysis,
98 })
99 }
100}
101
102impl CommitAnalysis {
103 pub fn analyze_commit(repo: &Repository, commit: &Commit) -> Result<Self> {
105 let file_changes = Self::analyze_file_changes(repo, commit)?;
107
108 let detected_type = Self::detect_commit_type(commit, &file_changes);
110
111 let detected_scope = Self::detect_scope(&file_changes);
113
114 let proposed_message =
116 Self::generate_proposed_message(commit, &detected_type, &detected_scope, &file_changes);
117
118 let diff_summary = Self::get_diff_summary(repo, commit)?;
120
121 let diff_content = Self::get_diff_content(repo, commit)?;
123
124 Ok(Self {
125 detected_type,
126 detected_scope,
127 proposed_message,
128 file_changes,
129 diff_summary,
130 diff_content,
131 })
132 }
133
134 fn analyze_file_changes(repo: &Repository, commit: &Commit) -> Result<FileChanges> {
136 let mut file_list = Vec::new();
137 let mut files_added = 0;
138 let mut files_deleted = 0;
139
140 let commit_tree = commit.tree().context("Failed to get commit tree")?;
142
143 let parent_tree = if commit.parent_count() > 0 {
145 Some(
146 commit
147 .parent(0)
148 .context("Failed to get parent commit")?
149 .tree()
150 .context("Failed to get parent tree")?,
151 )
152 } else {
153 None
154 };
155
156 let diff = if let Some(parent_tree) = parent_tree {
158 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
159 .context("Failed to create diff")?
160 } else {
161 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
163 .context("Failed to create diff for initial commit")?
164 };
165
166 diff.foreach(
168 &mut |delta, _progress| {
169 let status = match delta.status() {
170 git2::Delta::Added => {
171 files_added += 1;
172 "A"
173 }
174 git2::Delta::Deleted => {
175 files_deleted += 1;
176 "D"
177 }
178 git2::Delta::Modified => "M",
179 git2::Delta::Renamed => "R",
180 git2::Delta::Copied => "C",
181 git2::Delta::Typechange => "T",
182 _ => "?",
183 };
184
185 if let Some(path) = delta.new_file().path() {
186 if let Some(path_str) = path.to_str() {
187 file_list.push(FileChange {
188 status: status.to_string(),
189 file: path_str.to_string(),
190 });
191 }
192 }
193
194 true
195 },
196 None,
197 None,
198 None,
199 )
200 .context("Failed to process diff")?;
201
202 let total_files = file_list.len();
203
204 Ok(FileChanges {
205 total_files,
206 files_added,
207 files_deleted,
208 file_list,
209 })
210 }
211
212 fn detect_commit_type(commit: &Commit, file_changes: &FileChanges) -> String {
214 let message = commit.message().unwrap_or("");
215
216 if let Some(existing_type) = Self::extract_conventional_type(message) {
218 return existing_type;
219 }
220
221 let files: Vec<&str> = file_changes
223 .file_list
224 .iter()
225 .map(|f| f.file.as_str())
226 .collect();
227
228 if files
230 .iter()
231 .any(|f| f.contains("test") || f.contains("spec"))
232 {
233 "test".to_string()
234 } else if files
235 .iter()
236 .any(|f| f.ends_with(".md") || f.contains("README") || f.contains("docs/"))
237 {
238 "docs".to_string()
239 } else if files
240 .iter()
241 .any(|f| f.contains("Cargo.toml") || f.contains("package.json") || f.contains("config"))
242 {
243 if file_changes.files_added > 0 {
244 "feat".to_string()
245 } else {
246 "chore".to_string()
247 }
248 } else if file_changes.files_added > 0
249 && files
250 .iter()
251 .any(|f| f.ends_with(".rs") || f.ends_with(".js") || f.ends_with(".py"))
252 {
253 "feat".to_string()
254 } else if message.to_lowercase().contains("fix") || message.to_lowercase().contains("bug") {
255 "fix".to_string()
256 } else if file_changes.files_deleted > file_changes.files_added {
257 "refactor".to_string()
258 } else {
259 "chore".to_string()
260 }
261 }
262
263 fn extract_conventional_type(message: &str) -> Option<String> {
265 let first_line = message.lines().next().unwrap_or("");
266 if let Some(colon_pos) = first_line.find(':') {
267 let prefix = &first_line[..colon_pos];
268 if let Some(paren_pos) = prefix.find('(') {
269 let type_part = &prefix[..paren_pos];
270 if Self::is_valid_conventional_type(type_part) {
271 return Some(type_part.to_string());
272 }
273 } else if Self::is_valid_conventional_type(prefix) {
274 return Some(prefix.to_string());
275 }
276 }
277 None
278 }
279
280 fn is_valid_conventional_type(s: &str) -> bool {
282 matches!(
283 s,
284 "feat"
285 | "fix"
286 | "docs"
287 | "style"
288 | "refactor"
289 | "test"
290 | "chore"
291 | "build"
292 | "ci"
293 | "perf"
294 )
295 }
296
297 fn detect_scope(file_changes: &FileChanges) -> String {
299 let files: Vec<&str> = file_changes
300 .file_list
301 .iter()
302 .map(|f| f.file.as_str())
303 .collect();
304
305 if files.iter().any(|f| f.starts_with("src/cli/")) {
307 "cli".to_string()
308 } else if files.iter().any(|f| f.starts_with("src/git/")) {
309 "git".to_string()
310 } else if files.iter().any(|f| f.starts_with("src/data/")) {
311 "data".to_string()
312 } else if files.iter().any(|f| f.starts_with("tests/")) {
313 "test".to_string()
314 } else if files.iter().any(|f| f.starts_with("docs/")) {
315 "docs".to_string()
316 } else if files
317 .iter()
318 .any(|f| f.contains("Cargo.toml") || f.contains("deny.toml"))
319 {
320 "deps".to_string()
321 } else {
322 "".to_string()
323 }
324 }
325
326 fn generate_proposed_message(
328 commit: &Commit,
329 commit_type: &str,
330 scope: &str,
331 file_changes: &FileChanges,
332 ) -> String {
333 let current_message = commit.message().unwrap_or("").lines().next().unwrap_or("");
334
335 if Self::extract_conventional_type(current_message).is_some() {
337 return current_message.to_string();
338 }
339
340 let description =
342 if !current_message.is_empty() && !current_message.eq_ignore_ascii_case("stuff") {
343 current_message.to_string()
344 } else {
345 Self::generate_description(commit_type, file_changes)
346 };
347
348 if scope.is_empty() {
350 format!("{}: {}", commit_type, description)
351 } else {
352 format!("{}({}): {}", commit_type, scope, description)
353 }
354 }
355
356 fn generate_description(commit_type: &str, file_changes: &FileChanges) -> String {
358 match commit_type {
359 "feat" => {
360 if file_changes.total_files == 1 {
361 format!("add {}", file_changes.file_list[0].file)
362 } else {
363 format!("add {} new features", file_changes.total_files)
364 }
365 }
366 "fix" => "resolve issues".to_string(),
367 "docs" => "update documentation".to_string(),
368 "test" => "add tests".to_string(),
369 "refactor" => "improve code structure".to_string(),
370 "chore" => "update project files".to_string(),
371 _ => "update project".to_string(),
372 }
373 }
374
375 fn get_diff_summary(repo: &Repository, commit: &Commit) -> Result<String> {
377 let commit_tree = commit.tree().context("Failed to get commit tree")?;
378
379 let parent_tree = if commit.parent_count() > 0 {
380 Some(
381 commit
382 .parent(0)
383 .context("Failed to get parent commit")?
384 .tree()
385 .context("Failed to get parent tree")?,
386 )
387 } else {
388 None
389 };
390
391 let diff = if let Some(parent_tree) = parent_tree {
392 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
393 .context("Failed to create diff")?
394 } else {
395 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
396 .context("Failed to create diff for initial commit")?
397 };
398
399 let stats = diff.stats().context("Failed to get diff stats")?;
400
401 let mut summary = String::new();
402 for i in 0..stats.files_changed() {
403 if let Some(path) = diff
404 .get_delta(i)
405 .and_then(|d| d.new_file().path())
406 .and_then(|p| p.to_str())
407 {
408 let insertions = stats.insertions();
409 let deletions = stats.deletions();
410 summary.push_str(&format!(
411 " {} | {} +{} -{}\n",
412 path,
413 insertions + deletions,
414 insertions,
415 deletions
416 ));
417 }
418 }
419
420 Ok(summary)
421 }
422
423 fn get_diff_content(repo: &Repository, commit: &Commit) -> Result<String> {
425 let commit_tree = commit.tree().context("Failed to get commit tree")?;
426
427 let parent_tree = if commit.parent_count() > 0 {
428 Some(
429 commit
430 .parent(0)
431 .context("Failed to get parent commit")?
432 .tree()
433 .context("Failed to get parent tree")?,
434 )
435 } else {
436 None
437 };
438
439 let diff = if let Some(parent_tree) = parent_tree {
440 repo.diff_tree_to_tree(Some(&parent_tree), Some(&commit_tree), None)
441 .context("Failed to create diff")?
442 } else {
443 repo.diff_tree_to_tree(None, Some(&commit_tree), None)
444 .context("Failed to create diff for initial commit")?
445 };
446
447 let mut diff_content = String::new();
448
449 diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
450 let content = std::str::from_utf8(line.content()).unwrap_or("<binary>");
451 let prefix = match line.origin() {
452 '+' => "+",
453 '-' => "-",
454 ' ' => " ",
455 '@' => "@",
456 'H' => "", 'F' => "", _ => "",
459 };
460 diff_content.push_str(&format!("{}{}", prefix, content));
461 true
462 })
463 .context("Failed to format diff")?;
464
465 if !diff_content.ends_with('\n') {
467 diff_content.push('\n');
468 }
469
470 Ok(diff_content)
471 }
472}