1use anyhow::Result;
2use async_openai::config::OpenAIConfig;
3use async_openai::types::chat::{
4 ChatCompletionMessageToolCalls, ChatCompletionNamedToolChoice, ChatCompletionRequestSystemMessageArgs, ChatCompletionRequestUserMessageArgs, ChatCompletionToolChoiceOption, ChatCompletionTools, CreateChatCompletionRequestArgs
5};
6use async_openai::Client;
7use serde_json::Value;
8use futures::future::join_all;
9
10use crate::multi_step_analysis::{
11 create_analyze_function_tool, create_generate_function_tool, create_score_function_tool, FileDataForScoring, FileWithScore
12};
13use crate::function_calling::{create_commit_function_tool, CommitFunctionArgs};
14use crate::debug_output;
15
16pub const ANALYZE_SYSTEM_PROMPT: &str = "You are a senior software engineer analyzing one file's changes from a git diff. \
20Report only what the diff shows: count added and removed lines, classify the file's category, \
21and summarize the change in one short clause using imperative mood (\"add\", \"fix\", \"remove\"), \
22not past tense. Describe the functional impact of the change, not a mechanical line-by-line restatement. \
23Do not invent file names, symbols, or behavior that the diff does not contain. \
24Return your analysis only through the supplied function.";
25
26pub const SCORE_SYSTEM_PROMPT: &str = "You are scoring the relative impact of each changed file in a commit. \
28Weigh functional significance first: core source and behavior-changing config rank above tests, docs, \
29generated output, and binaries, and larger or higher-risk changes rank above trivial ones. \
30Assign each file a normalized impact score from 0.0 (negligible) to 1.0 (dominant change). \
31Return the scores only through the supplied function.";
32
33pub const GENERATE_SYSTEM_PROMPT: &str = "You are an expert engineer writing git commit subject lines. \
35Produce concise candidate messages that summarize the most impactful change first. \
36Use the imperative mood (\"Add feature\", not \"Added feature\"), no trailing period, and stay within the \
37character limit given in the request. State what the change does and why it matters, not how it is implemented; \
38prioritize functional impact over mechanical file-by-file description. \
39Do not invent changes that are not supported by the provided files. \
40Return the candidates only through the supplied function.";
41
42pub const COMMIT_SYSTEM_PROMPT: &str = "You are an expert engineer finalizing a git commit message from a multi-step analysis. \
44Select the single best candidate or refine one into a clear subject line. \
45Use the imperative mood (\"Fix crash\", not \"Fixed crash\"), keep the subject within the character limit, \
46use no trailing period, and lead with the change of highest impact. \
47Describe what changed and why, not how; do not invent changes absent from the diff. \
48Return the final message only through the supplied commit function.";
49
50#[derive(Debug)]
52pub struct ParsedFile {
53 pub path: String,
54 pub operation: String,
55 pub diff_content: String
56}
57
58fn analysis_u64(analysis: &Value, field: &str, default: u64) -> u64 {
62 match analysis.get(field).and_then(Value::as_u64) {
63 Some(v) => v,
64 None => {
65 log::debug!("analysis response missing/invalid '{field}' (u64); defaulting to {default}");
66 default
67 }
68 }
69}
70
71fn analysis_str(analysis: &Value, field: &str, default: &str) -> String {
75 match analysis.get(field).and_then(Value::as_str) {
76 Some(v) => v.to_string(),
77 None => {
78 log::debug!("analysis response missing/invalid '{field}' (str); defaulting to '{default}'");
79 default.to_string()
80 }
81 }
82}
83
84pub async fn generate_commit_message_multi_step(
86 client: &Client<OpenAIConfig>, model: &str, diff_content: &str, max_length: Option<usize>
87) -> Result<String> {
88 log::info!("Starting multi-step commit message generation");
89
90 if let Some(session) = debug_output::debug_session() {
92 session.init_multi_step_debug();
93 }
94
95 let parsed_files = parse_diff(diff_content)?;
97 log::info!("Parsed {} files from diff", parsed_files.len());
98
99 if let Some(session) = debug_output::debug_session() {
101 session.set_total_files_parsed(parsed_files.len());
102 }
103
104 log::debug!("Analyzing {} files in parallel", parsed_files.len());
106
107 let analysis_futures: Vec<_> = parsed_files
109 .iter()
110 .map(|file| {
111 let file_path = file.path.clone();
112 let operation = file.operation.clone();
113 async move {
114 log::debug!("Analyzing file: {file_path}");
115 let start_time = std::time::Instant::now();
116 let payload = format!("{{\"file_path\": \"{file_path}\", \"operation_type\": \"{operation}\", \"diff_content\": \"...\"}}");
117
118 let result = call_analyze_function(client, model, file).await;
119 let duration = start_time.elapsed();
120 (file, result, duration, payload)
121 }
122 })
123 .collect();
124
125 let analysis_results = join_all(analysis_futures).await;
127
128 let mut file_analyses = Vec::new();
130 for (i, (file, result, duration, payload)) in analysis_results.into_iter().enumerate() {
131 match result {
132 Ok(analysis) => {
133 log::debug!("Successfully analyzed file {}: {}", i, file.path);
134
135 let analysis_result = crate::multi_step_analysis::FileAnalysisResult {
137 lines_added: analysis_u64(&analysis, "lines_added", 0) as u32,
138 lines_removed: analysis_u64(&analysis, "lines_removed", 0) as u32,
139 file_category: analysis_str(&analysis, "file_category", "source"),
140 summary: analysis_str(&analysis, "summary", "")
141 };
142
143 if let Some(session) = debug_output::debug_session() {
145 session.add_file_analysis_debug(file.path.clone(), file.operation.clone(), analysis_result.clone(), duration, payload);
146 }
147
148 file_analyses.push((file, analysis));
149 }
150 Err(e) => {
151 let error_str = e.to_string();
153 if error_str.contains("invalid_api_key") || error_str.contains("Incorrect API key") || error_str.contains("Invalid API key") {
154 return Err(e);
155 }
156 log::warn!("Failed to analyze file {}: {}", file.path, e);
157 }
159 }
160 }
161
162 if file_analyses.is_empty() {
163 anyhow::bail!("Failed to analyze any files");
164 }
165
166 let files_data: Vec<FileDataForScoring> = file_analyses
168 .iter()
169 .map(|(file, analysis)| {
170 FileDataForScoring {
171 file_path: file.path.clone(),
172 operation_type: file.operation.clone(),
173 lines_added: analysis_u64(analysis, "lines_added", 0) as u32,
174 lines_removed: analysis_u64(analysis, "lines_removed", 0) as u32,
175 file_category: analysis_str(analysis, "file_category", "source"),
176 summary: analysis_str(analysis, "summary", "")
177 }
178 })
179 .collect();
180
181 let score_start_time = std::time::Instant::now();
183 let score_payload = format!(
184 "{{\"files_data\": [{{\"{}\", ...}}, ...]}}",
185 if !files_data.is_empty() {
186 &files_data[0].file_path
187 } else {
188 "no files"
189 }
190 );
191
192 let score_future = call_score_function(client, model, files_data);
195
196 let scored_files = score_future.await?;
198 let score_duration = score_start_time.elapsed();
199
200 if let Some(session) = debug_output::debug_session() {
202 session.set_score_debug(scored_files.clone(), score_duration, score_payload);
203 }
204
205 let generate_start_time = std::time::Instant::now();
207 let generate_payload = format!("{{\"files_with_scores\": [...], \"max_length\": {}}}", max_length.unwrap_or(72));
208
209 let generate_future = call_generate_function(client, model, scored_files.clone(), max_length.unwrap_or(72));
211
212 let candidates = generate_future.await?;
213 let generate_duration = generate_start_time.elapsed();
214
215 if let Some(session) = debug_output::debug_session() {
217 session.set_generate_debug(candidates.clone(), generate_duration, generate_payload);
218 }
219
220 let final_message_start_time = std::time::Instant::now();
222 let final_message = select_best_candidate(client, model, &candidates, &scored_files, diff_content, max_length.unwrap_or(72)).await?;
223 let final_message_duration = final_message_start_time.elapsed();
224
225 if let Some(session) = debug_output::debug_session() {
227 session.set_final_message_debug(final_message_duration);
228 session.set_commit_result(final_message.clone(), candidates["reasoning"].as_str().unwrap_or("").to_string());
229 }
230
231 Ok(final_message)
232}
233
234fn extract_file_path_from_diff_parts(parts: &[&str]) -> Option<String> {
243 if parts.len() < 4 {
244 return None;
245 }
246
247 let strip_prefix = |s: &str| {
249 s.trim_start_matches("a/")
250 .trim_start_matches("b/")
251 .trim_start_matches("c/")
252 .trim_start_matches("i/")
253 .to_string()
254 };
255
256 let new_path = strip_prefix(parts[3]);
257 let old_path = strip_prefix(parts[2]);
258
259 Some(if new_path == "/dev/null" || new_path == "dev/null" {
261 old_path
262 } else {
263 new_path
264 })
265}
266
267pub fn parse_diff(diff_content: &str) -> Result<Vec<ParsedFile>> {
269 let mut files = Vec::new();
270 let mut current_file: Option<ParsedFile> = None;
271 let mut current_diff = String::new();
272
273 log::debug!("Parsing diff with {} lines", diff_content.lines().count());
275
276 if log::log_enabled!(log::Level::Debug) && !diff_content.is_empty() {
278 let preview = if diff_content.len() > 500 {
280 let truncated_index = diff_content
281 .char_indices()
282 .take_while(|(i, _)| *i < 500)
283 .last()
284 .map(|(i, c)| i + c.len_utf8())
285 .unwrap_or(0);
286
287 format!("{}... (truncated)", &diff_content[..truncated_index])
288 } else {
289 diff_content.to_string()
290 };
291 log::debug!("Diff content preview: \n{preview}");
292 }
293
294 let mut in_diff_section = false;
296 let mut _commit_hash_line: Option<&str> = None;
297
298 for line in diff_content.lines().take(3) {
300 if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
301 _commit_hash_line = Some(line);
302 break;
303 }
304 }
305
306 for line in diff_content.lines() {
308 if line.starts_with("commit ") || (line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit())) || line.is_empty() {
310 continue;
311 }
312
313 if line.starts_with("diff --git") {
315 in_diff_section = true;
316 if let Some(mut file) = current_file.take() {
318 file.diff_content = current_diff.clone();
319 log::debug!("Adding file to results: {} ({})", file.path, file.operation);
320 files.push(file);
321 current_diff.clear();
322 }
323
324 let parts: Vec<&str> = line.split_whitespace().collect();
326 if let Some(path) = extract_file_path_from_diff_parts(&parts) {
327 log::debug!("Found new file in diff: {path}");
328 current_file = Some(ParsedFile {
329 path,
330 operation: "modified".to_string(), diff_content: String::new()
332 });
333 }
334
335 current_diff.push_str(line);
337 current_diff.push('\n');
338 } else if line.starts_with("new file mode") {
339 if let Some(ref mut file) = current_file {
340 log::debug!("File {} is newly added", file.path);
341 file.operation = "added".to_string();
342 }
343 current_diff.push_str(line);
344 current_diff.push('\n');
345 } else if line.starts_with("deleted file mode") {
346 if let Some(ref mut file) = current_file {
347 log::debug!("File {} is deleted", file.path);
348 file.operation = "deleted".to_string();
349 }
350 current_diff.push_str(line);
351 current_diff.push('\n');
352 } else if line.starts_with("rename from") || line.starts_with("rename to") {
353 if let Some(ref mut file) = current_file {
354 log::debug!("File {} is renamed", file.path);
355 file.operation = "renamed".to_string();
356 }
357 current_diff.push_str(line);
358 current_diff.push('\n');
359 } else if line.starts_with("Binary files") {
360 if let Some(ref mut file) = current_file {
361 log::debug!("File {} is binary", file.path);
362 file.operation = "binary".to_string();
363 }
364 current_diff.push_str(line);
365 current_diff.push('\n');
366 } else if line.starts_with("index ") || line.starts_with("--- ") || line.starts_with("+++ ") || line.starts_with("@@ ") {
367 current_diff.push_str(line);
369 current_diff.push('\n');
370 } else if in_diff_section {
371 current_diff.push_str(line);
372 current_diff.push('\n');
373 }
374 }
375
376 if let Some(mut file) = current_file {
378 file.diff_content = current_diff;
379 log::debug!("Adding final file to results: {} ({})", file.path, file.operation);
380 files.push(file);
381 }
382
383 if files.is_empty() && !diff_content.trim().is_empty() {
386 log::debug!("Trying to parse as raw git diff output with commit info");
387
388 let sections: Vec<&str> = diff_content.split("diff --git").skip(1).collect();
390
391 if !sections.is_empty() {
392 for (i, section) in sections.iter().enumerate() {
393 let full_section = format!("diff --git{section}");
395
396 let mut found_path = false;
398
399 let mut extracted_path = String::new();
401 for section_line in full_section.lines().take(3) {
402 if section_line.starts_with("diff --git") {
403 let parts: Vec<&str> = section_line.split_whitespace().collect();
404 if let Some(p) = extract_file_path_from_diff_parts(&parts) {
405 extracted_path = p;
406 found_path = true;
407 break;
408 }
409 }
410 }
411
412 if found_path {
413 log::debug!("Found file in section {i}: {extracted_path}");
414 files.push(ParsedFile {
415 path: extracted_path,
416 operation: "modified".to_string(), diff_content: full_section
418 });
419 }
420 }
421 }
422 }
423
424 if files.is_empty() && !diff_content.trim().is_empty() {
426 log::debug!("No standard diff format found, treating as single file change");
427 files.push(ParsedFile {
428 path: "unknown".to_string(),
429 operation: "modified".to_string(),
430 diff_content: diff_content.to_string()
431 });
432 }
433
434 log::debug!("Parsed {} files from diff", files.len());
435
436 if log::log_enabled!(log::Level::Debug) {
438 for (i, file) in files.iter().enumerate() {
439 let content_preview = if file.diff_content.len() > 200 {
440 let truncated_index = file
442 .diff_content
443 .char_indices()
444 .take_while(|(i, _)| *i < 200)
445 .last()
446 .map(|(i, c)| i + c.len_utf8())
447 .unwrap_or(0);
448
449 format!("{}... (truncated)", &file.diff_content[..truncated_index])
450 } else {
451 file.diff_content.clone()
452 };
453 log::debug!("File {}: {} ({})\nContent preview:\n{}", i, file.path, file.operation, content_preview);
454 }
455 }
456
457 Ok(files)
458}
459
460async fn call_analyze_function(client: &Client<OpenAIConfig>, model: &str, file: &ParsedFile) -> Result<Value> {
462 let tools = vec![ChatCompletionTools::Function(create_analyze_function_tool()?)];
463
464 let system_message = ChatCompletionRequestSystemMessageArgs::default()
465 .content(ANALYZE_SYSTEM_PROMPT)
466 .build()?
467 .into();
468
469 let user_message = ChatCompletionRequestUserMessageArgs::default()
470 .content(format!(
471 "Analyze this file change:\nPath: {}\nOperation: {}\nDiff:\n{}",
472 file.path, file.operation, file.diff_content
473 ))
474 .build()?
475 .into();
476
477 let request = CreateChatCompletionRequestArgs::default()
478 .model(model)
479 .messages(vec![system_message, user_message])
480 .tools(tools)
481 .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("analyze")))
482 .build()?;
483
484 let response = client.chat().create(request).await?;
485
486 if let Some(arguments) = first_function_call_arguments(&response) {
487 let args: Value = serde_json::from_str(arguments)?;
488 Ok(args)
489 } else {
490 anyhow::bail!("No tool call in response")
491 }
492}
493
494fn first_function_call_arguments(response: &async_openai::types::chat::CreateChatCompletionResponse) -> Option<&str> {
496 response
497 .choices
498 .first()?
499 .message
500 .tool_calls
501 .as_ref()
502 .and_then(|calls| calls.first())
503 .and_then(|call| {
504 match call {
505 ChatCompletionMessageToolCalls::Function(f) => Some(f.function.arguments.as_str()),
506 _ => None
507 }
508 })
509}
510
511async fn call_score_function(
513 client: &Client<OpenAIConfig>, model: &str, files_data: Vec<FileDataForScoring>
514) -> Result<Vec<FileWithScore>> {
515 let tools = vec![ChatCompletionTools::Function(create_score_function_tool()?)];
516
517 let system_message = ChatCompletionRequestSystemMessageArgs::default()
518 .content(SCORE_SYSTEM_PROMPT)
519 .build()?
520 .into();
521
522 let user_message = ChatCompletionRequestUserMessageArgs::default()
523 .content(format!(
524 "Calculate impact scores for these {} file changes:\n{}",
525 files_data.len(),
526 serde_json::to_string_pretty(&files_data)?
527 ))
528 .build()?
529 .into();
530
531 let request = CreateChatCompletionRequestArgs::default()
532 .model(model)
533 .messages(vec![system_message, user_message])
534 .tools(tools)
535 .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("score")))
536 .build()?;
537
538 let response = client.chat().create(request).await?;
539
540 if let Some(arguments) = first_function_call_arguments(&response) {
541 let args: Value = serde_json::from_str(arguments)?;
542 let files_with_scores: Vec<FileWithScore> = if args["files_with_scores"].is_null() {
543 Vec::new() } else {
545 serde_json::from_value(args["files_with_scores"].clone())?
546 };
547 Ok(files_with_scores)
548 } else {
549 anyhow::bail!("No tool call in response")
550 }
551}
552
553async fn call_generate_function(
555 client: &Client<OpenAIConfig>, model: &str, files_with_scores: Vec<FileWithScore>, max_length: usize
556) -> Result<Value> {
557 let tools = vec![ChatCompletionTools::Function(create_generate_function_tool()?)];
558
559 let system_message = ChatCompletionRequestSystemMessageArgs::default()
560 .content(GENERATE_SYSTEM_PROMPT)
561 .build()?
562 .into();
563
564 let user_message = ChatCompletionRequestUserMessageArgs::default()
565 .content(format!(
566 "Generate commit message candidates (max {} chars) for these scored changes:\n{}",
567 max_length,
568 serde_json::to_string_pretty(&files_with_scores)?
569 ))
570 .build()?
571 .into();
572
573 let request = CreateChatCompletionRequestArgs::default()
574 .model(model)
575 .messages(vec![system_message, user_message])
576 .tools(tools)
577 .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("generate")))
578 .build()?;
579
580 let response = client.chat().create(request).await?;
581
582 if let Some(arguments) = first_function_call_arguments(&response) {
583 let args: Value = serde_json::from_str(arguments)?;
584 Ok(args)
585 } else {
586 anyhow::bail!("No tool call in response")
587 }
588}
589
590async fn select_best_candidate(
592 client: &Client<OpenAIConfig>, model: &str, candidates: &Value, scored_files: &[FileWithScore], original_diff: &str, max_length: usize
593) -> Result<String> {
594 let tools = vec![ChatCompletionTools::Function(create_commit_function_tool(Some(max_length))?)];
597
598 let system_message = ChatCompletionRequestSystemMessageArgs::default()
599 .content(COMMIT_SYSTEM_PROMPT)
600 .build()?
601 .into();
602
603 let user_message = ChatCompletionRequestUserMessageArgs::default()
604 .content(format!(
605 "Based on this multi-step analysis:\n\n\
606 Candidates: {}\n\
607 Reasoning: {}\n\n\
608 Scored files: {}\n\n\
609 Original diff:\n{}\n\n\
610 Select the best commit message and format the response using the commit function.",
611 serde_json::to_string_pretty(&candidates["candidates"])?,
612 candidates["reasoning"].as_str().unwrap_or(""),
613 serde_json::to_string_pretty(&scored_files)?,
614 original_diff
615 ))
616 .build()?
617 .into();
618
619 let request = CreateChatCompletionRequestArgs::default()
620 .model(model)
621 .messages(vec![system_message, user_message])
622 .tools(tools)
623 .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("commit")))
624 .build()?;
625
626 let response = client.chat().create(request).await?;
627
628 if let Some(arguments) = first_function_call_arguments(&response) {
629 let raw_args: serde_json::Value = serde_json::from_str(arguments)?;
631
632 if let Some(message) = raw_args.get("message").and_then(|m| m.as_str()) {
634 return Ok(message.to_string());
635 }
636
637 let args: CommitFunctionArgs = serde_json::from_str(arguments)?;
639 Ok(args.message)
640 } else {
641 anyhow::bail!("No tool call in response")
642 }
643}
644
645pub fn generate_commit_message_local(diff_content: &str, max_length: Option<usize>) -> Result<String> {
647 use crate::multi_step_analysis::{analyze_file, calculate_impact_scores, generate_commit_messages};
648
649 log::info!("Starting local multi-step commit message generation");
650
651 let parsed_files = parse_diff(diff_content)?;
653
654 if let Some(session) = debug_output::debug_session() {
656 session.set_total_files_parsed(parsed_files.len());
657 }
658
659 let mut files_data = Vec::new();
661 for file in parsed_files {
662 let analysis = analyze_file(&file.path, &file.diff_content, &file.operation);
663 files_data.push(FileDataForScoring {
664 file_path: file.path,
665 operation_type: file.operation,
666 lines_added: analysis.lines_added,
667 lines_removed: analysis.lines_removed,
668 file_category: analysis.file_category,
669 summary: analysis.summary
670 });
671 }
672
673 let score_result = calculate_impact_scores(files_data);
675
676 let generate_result = generate_commit_messages(score_result.files_with_scores, max_length.unwrap_or(72));
678
679 match generate_result.candidates.first() {
682 Some(candidate) => Ok(candidate.clone()),
683 None => {
684 log::warn!("Local multi-step generation produced no candidates; falling back to 'Update files'");
685 Ok("Update files".to_string())
686 }
687 }
688}
689
690#[cfg(test)]
691mod tests {
692 use super::*;
693
694 #[test]
697 fn test_first_function_call_arguments_empty_choices_no_panic() {
698 let json = serde_json::json!({
699 "id": "chatcmpl-test",
700 "choices": [],
701 "created": 0,
702 "model": "gpt-4.1",
703 "object": "chat.completion",
704 "usage": null
705 });
706 let response: async_openai::types::chat::CreateChatCompletionResponse =
707 serde_json::from_value(json).expect("should deserialize a response with empty choices");
708
709 assert!(first_function_call_arguments(&response).is_none());
711 }
712
713 #[test]
714 fn test_parse_diff() {
715 let diff = r#"diff --git a/src/main.rs b/src/main.rs
716index 1234567..abcdefg 100644
717--- a/src/main.rs
718+++ b/src/main.rs
719@@ -1,5 +1,6 @@
720 fn main() {
721- println!("Hello");
722+ println!("Hello, world!");
723+ println!("New line");
724 }
725diff --git a/Cargo.toml b/Cargo.toml
726new file mode 100644
727index 0000000..1111111
728--- /dev/null
729+++ b/Cargo.toml
730@@ -0,0 +1,8 @@
731+[package]
732+name = "test"
733+version = "0.1.0"
734"#;
735
736 let files = parse_diff(diff).unwrap();
737 assert_eq!(files.len(), 2);
738 assert_eq!(files[0].path, "src/main.rs");
739 assert_eq!(files[0].operation, "modified");
740 assert_eq!(files[1].path, "Cargo.toml");
741 assert_eq!(files[1].operation, "added");
742
743 assert!(!files[0].diff_content.is_empty());
745 assert!(!files[1].diff_content.is_empty());
746 }
747
748 #[test]
749 fn test_parse_diff_with_commit_hash() {
750 let diff = r#"0472ffa1665c4c5573fb8f7698c9965122eda675 Update files
752diff --git a/src/openai.rs b/src/openai.rs
753index a67ebbe..da223be 100644
754--- a/src/openai.rs
755+++ b/src/openai.rs
756@@ -15,11 +15,6 @@ use crate::multi_step_integration::{generate_commit_message_local, generate_comm
757
758 const MAX_ATTEMPTS: usize = 3;
759
760-#[derive(Debug, Clone, PartialEq)]
761-pub struct Response {
762- pub response: String
763-}
764-
765 #[derive(Debug, Clone, PartialEq)]
766 pub struct Request {
767 pub prompt: String,
768@@ -28,6 +23,11 @@ pub struct Request {
769 pub model: Model
770 }
771
772+#[derive(Debug, Clone, PartialEq)]
773+pub struct Response {
774+ pub response: String
775+}
776+
777 /// Generates an improved commit message using the provided prompt and diff
778 /// Now uses the multi-step approach by default
779 pub async fn generate_commit_message(diff: &str) -> Result<String> {
780"#;
781
782 let files = parse_diff(diff).unwrap();
783 assert_eq!(files.len(), 1);
784 assert_eq!(files[0].path, "src/openai.rs");
785 assert_eq!(files[0].operation, "modified");
786
787 assert!(files[0].diff_content.contains("pub struct Response"));
789
790 assert!(!files[0]
792 .diff_content
793 .contains("0472ffa1665c4c5573fb8f7698c9965122eda675"));
794 }
795
796 #[test]
797 fn test_parse_diff_with_c_i_prefixes() {
798 let diff = r#"diff --git c/test.md i/test.md
800new file mode 100644
801index 0000000..6c61a60
802--- /dev/null
803+++ i/test.md
804@@ -0,0 +1 @@
805+# Test File
806
807diff --git c/test.js i/test.js
808new file mode 100644
809index 0000000..a730e61
810--- /dev/null
811+++ i/test.js
812@@ -0,0 +1 @@
813+console.log('Hello');
814"#;
815
816 let files = parse_diff(diff).unwrap();
817 assert_eq!(files.len(), 2);
818 assert_eq!(files[0].path, "test.md", "Should extract clean path without i/ prefix");
819 assert_eq!(files[0].operation, "added");
820 assert_eq!(files[1].path, "test.js", "Should extract clean path without i/ prefix");
821 assert_eq!(files[1].operation, "added");
822
823 assert!(files[0].diff_content.contains("# Test File"));
825 assert!(files[1].diff_content.contains("console.log"));
826 }
827
828 #[test]
829 fn test_parse_diff_with_deleted_file() {
830 let diff = r#"diff --git a/deleted.txt b/dev/null
832deleted file mode 100644
833index 1234567..0000000
834--- a/deleted.txt
835+++ /dev/null
836@@ -1,3 +0,0 @@
837-This file
838-will be
839-deleted
840"#;
841
842 let files = parse_diff(diff).unwrap();
843 assert_eq!(files.len(), 1);
844 assert_eq!(files[0].path, "deleted.txt", "Should use a path for deleted files");
845 assert_eq!(files[0].operation, "deleted");
846
847 assert!(files[0].diff_content.contains("This file"));
849 }
850
851 #[test]
852 fn test_local_generation() {
853 let diff = r#"diff --git a/src/auth.rs b/src/auth.rs
854index 1234567..abcdefg 100644
855--- a/src/auth.rs
856+++ b/src/auth.rs
857@@ -10,7 +10,15 @@ pub fn authenticate(user: &str, pass: &str) -> Result<Token> {
858- if user == "admin" && pass == "password" {
859- Ok(Token::new())
860- } else {
861- Err(AuthError::InvalidCredentials)
862- }
863+ // Validate input
864+ if user.is_empty() || pass.is_empty() {
865+ return Err(AuthError::EmptyCredentials);
866+ }
867+
868+ // Check credentials against database
869+ let hashed = hash_password(pass);
870+ if validate_user(user, &hashed)? {
871+ Ok(Token::generate(user))
872+ } else {
873+ Err(AuthError::InvalidCredentials)
874+ }
875 }"#;
876
877 let message = generate_commit_message_local(diff, Some(72)).unwrap();
878 assert!(!message.is_empty());
879 assert!(message.len() <= 72);
880 }
881}