Skip to main content

ai/
multi_step_integration.rs

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
16/// System prompt for the `analyze` step. Drives per-file analysis that feeds the
17/// `analyze` function-calling tool. Kept as a `pub const` so the prompt contract can be
18/// pinned by invariant tests without reaching into private request builders.
19pub 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
26/// System prompt for the `score` step (impact scoring across all analyzed files).
27pub 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
33/// System prompt for the `generate` step (candidate commit subjects).
34pub 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
42/// System prompt for the final `commit` step (select and format the message).
43pub 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/// Represents a parsed file from the git diff
51#[derive(Debug)]
52pub struct ParsedFile {
53  pub path:         String,
54  pub operation:    String,
55  pub diff_content: String
56}
57
58/// Reads a `u64` field from an analysis JSON object, logging at debug level when the
59/// field is absent so silently-defaulted values are visible. The default itself is
60/// unchanged from the prior `.as_u64().unwrap_or(default)` behavior.
61fn 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
71/// Reads a string field from an analysis JSON object, logging at debug level when the
72/// field is absent. The default itself is unchanged from the prior
73/// `.as_str().unwrap_or(default)` behavior.
74fn 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
84/// Main entry point for multi-step commit message generation
85pub 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  // Initialize multi-step debug session
91  if let Some(session) = debug_output::debug_session() {
92    session.init_multi_step_debug();
93  }
94
95  // Parse the diff to extract individual files
96  let parsed_files = parse_diff(diff_content)?;
97  log::info!("Parsed {} files from diff", parsed_files.len());
98
99  // Track files parsed in debug session
100  if let Some(session) = debug_output::debug_session() {
101    session.set_total_files_parsed(parsed_files.len());
102  }
103
104  // Step 1: Analyze each file individually in parallel
105  log::debug!("Analyzing {} files in parallel", parsed_files.len());
106
107  // Create futures for all file analyses
108  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  // Execute all analyses in parallel
126  let analysis_results = join_all(analysis_futures).await;
127
128  // Process results and handle errors
129  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        // Extract structured analysis data for debug
136        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        // Record in debug session
144        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        // Check if it's an API key error - if so, propagate it immediately
152        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        // Continue with other files even if one fails
158      }
159    }
160  }
161
162  if file_analyses.is_empty() {
163    anyhow::bail!("Failed to analyze any files");
164  }
165
166  // Step 2: Calculate impact scores
167  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  // Record impact score calculation
182  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  // Start step 2 and 3 in parallel
193  // First create the futures for both operations
194  let score_future = call_score_function(client, model, files_data);
195
196  // Run the scoring operation
197  let scored_files = score_future.await?;
198  let score_duration = score_start_time.elapsed();
199
200  // Record in debug session
201  if let Some(session) = debug_output::debug_session() {
202    session.set_score_debug(scored_files.clone(), score_duration, score_payload);
203  }
204
205  // Step 3: Generate commit message candidates
206  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  // Now create and run the generate and select steps in parallel
210  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  // Record in debug session
216  if let Some(session) = debug_output::debug_session() {
217    session.set_generate_debug(candidates.clone(), generate_duration, generate_payload);
218  }
219
220  // Step 4: Select the best candidate and format final response
221  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  // Record in debug session
226  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
234/// Extracts the file path from git diff header parts.
235/// Handles various git prefixes (a/, b/, c/, i/) and /dev/null for deleted files.
236///
237/// # Arguments
238/// * `parts` - The whitespace-split parts from a "diff --git" line
239///
240/// # Returns
241/// * `Option<String>` - The extracted path without prefixes, or None if parsing fails
242fn extract_file_path_from_diff_parts(parts: &[&str]) -> Option<String> {
243  if parts.len() < 4 {
244    return None;
245  }
246
247  // Helper to strip git prefixes (a/, b/, c/, i/)
248  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  // Prefer new path unless it's /dev/null (deleted file)
260  Some(if new_path == "/dev/null" || new_path == "dev/null" {
261    old_path
262  } else {
263    new_path
264  })
265}
266
267/// Parse git diff into individual files
268pub 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  // Debug output
274  log::debug!("Parsing diff with {} lines", diff_content.lines().count());
275
276  // Add more detailed logging for debugging
277  if log::log_enabled!(log::Level::Debug) && !diff_content.is_empty() {
278    // Make sure we truncate at a valid UTF-8 character boundary
279    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  // Handle different diff formats
295  let mut in_diff_section = false;
296  let mut _commit_hash_line: Option<&str> = None;
297
298  // First scan to detect if this is a commit message with hash
299  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  // Process line by line
307  for line in diff_content.lines() {
308    // Skip commit hash lines and other metadata
309    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    // Check if we're starting a new file diff
314    if line.starts_with("diff --git") {
315      in_diff_section = true;
316      // Save previous file if exists
317      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      // Extract file path more carefully
325      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(), // Default, will be updated
331          diff_content: String::new()
332        });
333      }
334
335      // Add the header line to the diff content
336      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      // These are important diff headers that should be included
368      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  // Don't forget the last file
377  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 we didn't parse any files, check if this looks like a raw git diff output
384  // from commands like `git show` that include commit info at the top
385  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    // Extract sections that start with "diff --git"
389    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        // Add the "diff --git" prefix back
394        let full_section = format!("diff --git{section}");
395
396        // Extract file path from the section more carefully
397        let mut found_path = false;
398
399        // Safer approach: iterate through lines and find the path
400        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(), // Default
417            diff_content: full_section
418          });
419        }
420      }
421    }
422  }
423
424  // If still no files were parsed, treat the entire diff as a single change
425  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  // Add detailed debug output for each parsed file
437  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        // Make sure we truncate at a valid UTF-8 character boundary
441        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
460/// Call the analyze function via OpenAI
461async 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
494/// Extracts the arguments of the first function tool call from a chat completion response.
495fn 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
511/// Call the score function via OpenAI
512async 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() // Return empty vector if null
544    } 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
553/// Call the generate function via OpenAI
554async 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
590/// Select the best candidate and format the final response
591async 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  // Use the original commit function to get the final formatted response,
595  // honoring the configured commit-length limit (was previously hardcoded to 72).
596  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    // First, parse as Value to manually handle required fields
630    let raw_args: serde_json::Value = serde_json::from_str(arguments)?;
631
632    // Extract the message which is what we really need
633    if let Some(message) = raw_args.get("message").and_then(|m| m.as_str()) {
634      return Ok(message.to_string());
635    }
636
637    // Fallback to full parsing if the above approach fails
638    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
645/// Alternative: Use the multi-step analysis locally without OpenAI calls
646pub 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  // Parse the diff
652  let parsed_files = parse_diff(diff_content)?;
653
654  // Track files parsed in debug session
655  if let Some(session) = debug_output::debug_session() {
656    session.set_total_files_parsed(parsed_files.len());
657  }
658
659  // Step 1: Analyze each file
660  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  // Step 2: Calculate scores
674  let score_result = calculate_impact_scores(files_data);
675
676  // Step 3: Generate candidates
677  let generate_result = generate_commit_messages(score_result.files_with_scores, max_length.unwrap_or(72));
678
679  // Return the first candidate. Keep a safe fallback, but surface the failure so a
680  // silent "Update files" message is never mistaken for a real generated message.
681  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  /// C2: An OpenAI response with an empty `choices` array must not panic. The helper
695  /// returns `None` (so callers produce a clean error) instead of indexing `choices[0]`.
696  #[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    // Must be None, not a panic.
710    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    // Verify files contain diff content
744    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    // Test with a commit hash and message before the diff
751    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    // Verify diff content contains actual changes
788    assert!(files[0].diff_content.contains("pub struct Response"));
789
790    // Verify commit hash line was skipped
791    assert!(!files[0]
792      .diff_content
793      .contains("0472ffa1665c4c5573fb8f7698c9965122eda675"));
794  }
795
796  #[test]
797  fn test_parse_diff_with_c_i_prefixes() {
798    // Test with c/ and i/ prefixes that appear in git hook diffs
799    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    // Verify files contain diff content
824    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    // Test with a deleted file (where b path is /dev/null)
831    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    // Verify file contains diff content
848    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}