1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use async_openai::types::chat::{ChatCompletionTool, FunctionObjectArgs};
4use anyhow::Result;
5#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct FileAnalysisResult {
10 pub lines_added: u32,
11 pub lines_removed: u32,
12 pub file_category: String,
13 pub summary: String
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct FileDataForScoring {
19 pub file_path: String,
20 pub operation_type: String,
21 pub lines_added: u32,
22 pub lines_removed: u32,
23 pub file_category: String,
24 pub summary: String
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct FileWithScore {
30 pub file_path: String,
31 pub operation_type: String,
32 pub lines_added: u32,
33 pub lines_removed: u32,
34 pub file_category: String,
35 pub summary: String,
36 pub impact_score: f32
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41pub struct ScoreResult {
42 pub files_with_scores: Vec<FileWithScore>
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct GenerateResult {
48 pub candidates: Vec<String>,
49 pub reasoning: String
50}
51
52pub fn create_analyze_function_tool() -> Result<ChatCompletionTool> {
54 log::debug!("Creating analyze function tool");
55
56 let function = FunctionObjectArgs::default()
57 .name("analyze")
58 .description("Analyze a single file's changes from the git diff")
59 .parameters(json!({
60 "type": "object",
61 "properties": {
62 "file_path": {
63 "type": "string",
64 "description": "Relative path to the file"
65 },
66 "diff_content": {
67 "type": "string",
68 "description": "The git diff content for this specific file only"
69 },
70 "operation_type": {
71 "type": "string",
72 "enum": ["added", "modified", "deleted", "renamed", "binary"],
73 "description": "Type of operation performed on the file"
74 }
75 },
76 "required": ["file_path", "diff_content", "operation_type"]
77 }))
78 .build()?;
79
80 Ok(ChatCompletionTool { function })
81}
82
83pub fn create_score_function_tool() -> Result<ChatCompletionTool> {
85 log::debug!("Creating score function tool");
86
87 let function = FunctionObjectArgs::default()
88 .name("score")
89 .description("Calculate impact scores for all analyzed files")
90 .parameters(json!({
91 "type": "object",
92 "properties": {
93 "files_data": {
94 "type": "array",
95 "description": "Array of analyzed file data",
96 "items": {
97 "type": "object",
98 "properties": {
99 "file_path": {
100 "type": "string",
101 "description": "Relative path to the file"
102 },
103 "operation_type": {
104 "type": "string",
105 "enum": ["added", "modified", "deleted", "renamed", "binary"],
106 "description": "Type of operation performed on the file"
107 },
108 "lines_added": {
109 "type": "integer",
110 "description": "Number of lines added",
111 "minimum": 0
112 },
113 "lines_removed": {
114 "type": "integer",
115 "description": "Number of lines removed",
116 "minimum": 0
117 },
118 "file_category": {
119 "type": "string",
120 "enum": ["source", "test", "config", "docs", "binary", "build"],
121 "description": "Category of the file"
122 },
123 "summary": {
124 "type": "string",
125 "description": "Brief description of changes"
126 }
127 },
128 "required": ["file_path", "operation_type", "lines_added", "lines_removed", "file_category", "summary"]
129 }
130 }
131 },
132 "required": ["files_data"]
133 }))
134 .build()?;
135
136 Ok(ChatCompletionTool { function })
137}
138
139pub fn create_generate_function_tool() -> Result<ChatCompletionTool> {
141 log::debug!("Creating generate function tool");
142
143 let function = FunctionObjectArgs::default()
144 .name("generate")
145 .description("Generate commit message candidates based on scored files")
146 .parameters(json!({
147 "type": "object",
148 "properties": {
149 "files_with_scores": {
150 "type": "array",
151 "description": "All files with calculated impact scores",
152 "items": {
153 "type": "object",
154 "properties": {
155 "file_path": {
156 "type": "string"
157 },
158 "operation_type": {
159 "type": "string"
160 },
161 "lines_added": {
162 "type": "integer"
163 },
164 "lines_removed": {
165 "type": "integer"
166 },
167 "file_category": {
168 "type": "string"
169 },
170 "summary": {
171 "type": "string"
172 },
173 "impact_score": {
174 "type": "number",
175 "minimum": 0.0,
176 "maximum": 1.0
177 }
178 },
179 "required": ["file_path", "operation_type", "lines_added", "lines_removed", "file_category", "summary", "impact_score"]
180 }
181 },
182 "max_length": {
183 "type": "integer",
184 "description": "Maximum character length for commit message",
185 "default": 72
186 }
187 },
188 "required": ["files_with_scores"]
189 }))
190 .build()?;
191
192 Ok(ChatCompletionTool { function })
193}
194
195pub fn analyze_file(file_path: &str, diff_content: &str, operation_type: &str) -> FileAnalysisResult {
197 log::debug!("Analyzing file: {file_path} ({operation_type})");
198
199 let mut lines_added = 0u32;
201 let mut lines_removed = 0u32;
202
203 for line in diff_content.lines() {
204 if line.starts_with('+') && !line.starts_with("+++") {
205 lines_added += 1;
206 } else if line.starts_with('-') && !line.starts_with("---") {
207 lines_removed += 1;
208 }
209 }
210
211 let file_category = categorize_file(file_path);
213
214 let summary = generate_file_summary(file_path, diff_content, operation_type);
216
217 log::debug!("File analysis complete: +{lines_added} -{lines_removed} lines, category: {file_category}");
218
219 FileAnalysisResult { lines_added, lines_removed, file_category, summary }
220}
221
222pub fn calculate_impact_scores(files_data: Vec<FileDataForScoring>) -> ScoreResult {
224 log::debug!("Calculating impact scores for {} files", files_data.len());
225
226 let mut files_with_scores = Vec::new();
227
228 for file_data in files_data {
229 let impact_score = calculate_single_impact_score(&file_data);
230
231 files_with_scores.push(FileWithScore {
232 file_path: file_data.file_path,
233 operation_type: file_data.operation_type,
234 lines_added: file_data.lines_added,
235 lines_removed: file_data.lines_removed,
236 file_category: file_data.file_category,
237 summary: file_data.summary,
238 impact_score
239 });
240 }
241
242 files_with_scores.sort_by(|a, b| {
244 b.impact_score
245 .partial_cmp(&a.impact_score)
246 .unwrap_or(std::cmp::Ordering::Equal)
247 });
248
249 ScoreResult { files_with_scores }
250}
251
252pub fn generate_commit_messages(files_with_scores: Vec<FileWithScore>, max_length: usize) -> GenerateResult {
254 log::debug!("Generating commit messages (max length: {max_length})");
255
256 let primary_change = files_with_scores.first();
258 let mut candidates = Vec::new();
259
260 if let Some(primary) = primary_change {
261 let action_msg = generate_action_message(primary, &files_with_scores, max_length);
265 candidates.push(action_msg);
266
267 let component_msg = generate_component_message(primary, &files_with_scores, max_length);
269 candidates.push(component_msg);
270
271 let impact_msg = generate_impact_message(primary, &files_with_scores, max_length);
273 candidates.push(impact_msg);
274 }
275
276 let reasoning = generate_reasoning(&files_with_scores);
277
278 GenerateResult { candidates, reasoning }
279}
280
281fn categorize_file(file_path: &str) -> String {
284 let path = file_path.to_lowercase();
285
286 if path.ends_with(".test.js")
287 || path.ends_with(".spec.js")
288 || path.ends_with("_test.go")
289 || path.ends_with("_test.rs")
290 || path.contains("/test/")
291 || path.contains("/tests/")
292 {
293 "test".to_string()
294 } else if path.ends_with(".md") || path.ends_with(".txt") || path.ends_with(".rst") || path.contains("/docs/") {
295 "docs".to_string()
296 } else if path == "package.json"
297 || path == "cargo.toml"
298 || path == "go.mod"
299 || path == "requirements.txt"
300 || path == "gemfile"
301 || path.ends_with(".lock")
302 {
303 "build".to_string()
304 } else if path.ends_with(".yml")
305 || path.ends_with(".yaml")
306 || path.ends_with(".json")
307 || path.ends_with(".toml")
308 || path.ends_with(".ini")
309 || path.ends_with(".conf")
310 || path.contains("config")
311 || path.contains(".github/")
312 {
313 "config".to_string()
314 } else if path.ends_with(".png")
315 || path.ends_with(".jpg")
316 || path.ends_with(".gif")
317 || path.ends_with(".ico")
318 || path.ends_with(".pdf")
319 || path.ends_with(".zip")
320 {
321 "binary".to_string()
322 } else {
323 "source".to_string()
324 }
325}
326
327fn generate_file_summary(file_path: &str, _diff_content: &str, operation_type: &str) -> String {
328 match operation_type {
331 "added" => format!("New {} file added", categorize_file(file_path)),
332 "deleted" => format!("Removed {} file", categorize_file(file_path)),
333 "renamed" => "File renamed".to_string(),
334 "binary" => "Binary file updated".to_string(),
335 _ => "File modified".to_string()
336 }
337}
338
339fn calculate_single_impact_score(file_data: &FileDataForScoring) -> f32 {
340 let mut score = 0.0f32;
341
342 score += match file_data.operation_type.as_str() {
344 "added" => 0.3,
345 "modified" => 0.2,
346 "deleted" => 0.25,
347 "renamed" => 0.1,
348 "binary" => 0.05,
349 _ => 0.1
350 };
351
352 score += match file_data.file_category.as_str() {
354 "source" => 0.4,
355 "test" => 0.2,
356 "config" => 0.25,
357 "build" => 0.3,
358 "docs" => 0.1,
359 "binary" => 0.05,
360 _ => 0.1
361 };
362
363 let total_lines = file_data.lines_added + file_data.lines_removed;
365 let line_score = (total_lines as f32 / 100.0).min(0.3);
366 score += line_score;
367
368 score.min(1.0) }
370
371fn truncate_subject(message: String, max_length: usize) -> String {
376 if message.chars().count() <= max_length {
377 return message;
378 }
379 let truncated: String = message.chars().take(max_length).collect();
380 match truncated.rfind(' ') {
381 Some(idx) if idx > 0 => truncated[..idx].trim_end().to_string(),
382 _ => truncated.trim_end().to_string()
383 }
384}
385
386fn generate_action_message(primary: &FileWithScore, _all_files: &[FileWithScore], max_length: usize) -> String {
387 let base = match primary.operation_type.as_str() {
388 "added" => "Add",
389 "modified" => "Update",
390 "deleted" => "Remove",
391 "renamed" => "Rename",
392 _ => "Change"
393 };
394
395 let component = extract_component_name(&primary.file_path);
396 let message = format!("{base} {component}");
397
398 truncate_subject(message, max_length)
399}
400
401fn generate_component_message(primary: &FileWithScore, _all_files: &[FileWithScore], max_length: usize) -> String {
402 let component = extract_component_name(&primary.file_path);
403 let action = match primary.operation_type.as_str() {
404 "added" => "implementation",
405 "modified" => "updates",
406 "deleted" => "removal",
407 _ => "changes"
408 };
409
410 let message = format!("{component}: {action}");
411
412 truncate_subject(message, max_length)
413}
414
415fn generate_impact_message(primary: &FileWithScore, all_files: &[FileWithScore], max_length: usize) -> String {
416 let impact_type = if all_files
417 .iter()
418 .any(|f| f.file_category == "source" && f.operation_type == "added")
419 {
420 "feature"
421 } else if all_files.iter().any(|f| f.file_category == "test") {
422 "test"
423 } else if all_files.iter().any(|f| f.file_category == "config") {
424 "configuration"
425 } else {
426 "update"
427 };
428
429 let component = extract_component_name(&primary.file_path);
430 let message = format!(
431 "{} {} for {}",
432 if impact_type == "feature" {
433 "New"
434 } else {
435 "Update"
436 },
437 impact_type,
438 component
439 );
440
441 truncate_subject(message, max_length)
442}
443
444fn extract_component_name(file_path: &str) -> String {
445 let path_parts: Vec<&str> = file_path.split('/').collect();
446
447 if let Some(filename) = path_parts.last() {
448 let name_parts: Vec<&str> = filename.split('.').collect();
450 if name_parts.len() > 1 {
451 name_parts[0].to_string()
452 } else {
453 filename.to_string()
454 }
455 } else {
456 "component".to_string()
457 }
458}
459
460fn generate_reasoning(files_with_scores: &[FileWithScore]) -> String {
461 if files_with_scores.is_empty() {
462 return "No files to analyze".to_string();
463 }
464
465 let primary = &files_with_scores[0];
466 let total_files = files_with_scores.len();
467 let total_lines: u32 = files_with_scores
468 .iter()
469 .map(|f| f.lines_added + f.lines_removed)
470 .sum();
471
472 format!(
473 "{} changes have highest impact ({:.2}) affecting {} functionality. \
474 Total {} files changed with {} lines modified.",
475 primary
476 .file_category
477 .chars()
478 .next()
479 .unwrap_or('u')
480 .to_uppercase()
481 .collect::<String>()
482 + primary.file_category.get(1..).unwrap_or(""),
483 primary.impact_score,
484 extract_component_name(&primary.file_path),
485 total_files,
486 total_lines
487 )
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn test_file_categorization() {
496 assert_eq!(categorize_file("src/main.rs"), "source");
497 assert_eq!(categorize_file("tests/integration_test.rs"), "test");
498 assert_eq!(categorize_file("package.json"), "build");
499 assert_eq!(categorize_file(".github/workflows/ci.yml"), "config");
500 assert_eq!(categorize_file("README.md"), "docs");
501 assert_eq!(categorize_file("logo.png"), "binary");
502 }
503
504 #[test]
505 fn test_impact_score_calculation() {
506 let file_data = FileDataForScoring {
507 file_path: "src/auth.rs".to_string(),
508 operation_type: "modified".to_string(),
509 lines_added: 50,
510 lines_removed: 20,
511 file_category: "source".to_string(),
512 summary: "Updated authentication logic".to_string()
513 };
514
515 let score = calculate_single_impact_score(&file_data);
516 assert!(score > 0.0 && score <= 1.0);
517 }
518
519 #[test]
520 fn test_truncate_subject_word_boundary() {
521 assert_eq!(truncate_subject("Update auth".to_string(), 72), "Update auth");
523 assert_eq!(truncate_subject("Update authentication service".to_string(), 17), "Update");
525 assert_eq!(truncate_subject("Updateauthentication".to_string(), 6), "Update");
527 }
528}