Skip to main content

ai/
simple_multi_step.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;
7
8use crate::function_calling::{create_commit_function_tool, CommitFunctionArgs};
9use crate::debug_output;
10
11/// Simplified multi-step commit message generation that works with raw diff
12pub async fn generate_commit_message_simple(
13  client: &Client<OpenAIConfig>, model: &str, diff_content: &str, max_length: Option<usize>
14) -> Result<String> {
15  log::info!("Starting simplified multi-step commit message generation");
16
17  // Initialize multi-step debug session
18  if let Some(session) = debug_output::debug_session() {
19    session.init_multi_step_debug();
20  }
21
22  // Use the commit function tool directly with the full diff
23  let tools = vec![ChatCompletionTools::Function(create_commit_function_tool(max_length)?)];
24
25  let system_message = ChatCompletionRequestSystemMessageArgs::default()
26    .content(
27      "You are a git commit message expert. Analyze the provided git diff and generate a concise, \
28       descriptive commit message. Focus on the most significant changes and their impact. \
29       The message should explain WHAT changed and WHY it matters."
30    )
31    .build()?
32    .into();
33
34  let user_message = ChatCompletionRequestUserMessageArgs::default()
35    .content(format!("Generate a commit message for the following git diff:\n\n{diff_content}"))
36    .build()?
37    .into();
38
39  let request = CreateChatCompletionRequestArgs::default()
40    .model(model)
41    .messages(vec![system_message, user_message])
42    .tools(tools)
43    .tool_choice(ChatCompletionToolChoiceOption::Function(ChatCompletionNamedToolChoice::from("commit")))
44    .build()?;
45
46  let response = client.chat().create(request).await?;
47
48  let tool_call_arguments = first_function_call_arguments(&response);
49
50  if let Some(arguments) = tool_call_arguments {
51    let args: CommitFunctionArgs = serde_json::from_str(arguments)?;
52
53    // Record in debug session
54    if let Some(session) = debug_output::debug_session() {
55      session.set_commit_result(args.message.clone(), args.reasoning.clone());
56      session.set_files_analyzed(args.clone());
57      // Set a dummy count since we're not parsing files
58      session.set_total_files_parsed(1);
59    }
60
61    Ok(args.message)
62  } else {
63    anyhow::bail!("No tool call in response")
64  }
65}
66
67/// Extracts the arguments of the first function tool call from a chat completion response.
68///
69/// Uses `.choices.first()` instead of `choices[0]` indexing so that an empty `choices`
70/// array (which the OpenAI API can legally return) yields `None` rather than panicking.
71/// Callers treat `None` as "no tool call" and return a clean error.
72fn first_function_call_arguments(response: &async_openai::types::chat::CreateChatCompletionResponse) -> Option<&str> {
73  response
74    .choices
75    .first()?
76    .message
77    .tool_calls
78    .as_ref()
79    .and_then(|calls| calls.first())
80    .and_then(|call| {
81      match call {
82        ChatCompletionMessageToolCalls::Function(f) => Some(f.function.arguments.as_str()),
83        _ => None
84      }
85    })
86}
87
88/// Local version that doesn't require parsing
89pub fn generate_commit_message_simple_local(diff_content: &str, max_length: Option<usize>) -> Result<String> {
90  log::info!("Starting simplified local commit message generation");
91
92  // Count basic statistics from the diff
93  let mut lines_added = 0;
94  let mut lines_removed = 0;
95  let mut files_mentioned = std::collections::HashSet::new();
96
97  for line in diff_content.lines() {
98    if line.starts_with("+++") || line.starts_with("---") {
99      if let Some(file) = line.split_whitespace().nth(1) {
100        files_mentioned.insert(file.trim_start_matches("a/").trim_start_matches("b/"));
101      }
102    } else if line.starts_with('+') && !line.starts_with("+++") {
103      lines_added += 1;
104    } else if line.starts_with('-') && !line.starts_with("---") {
105      lines_removed += 1;
106    }
107  }
108
109  // Track in debug session
110  if let Some(session) = debug_output::debug_session() {
111    session.set_total_files_parsed(files_mentioned.len());
112  }
113
114  // Generate a simple commit message based on the diff
115  let message = match files_mentioned.len().cmp(&1) {
116    std::cmp::Ordering::Equal => {
117      let file = files_mentioned
118        .iter()
119        .next()
120        .ok_or_else(|| anyhow::anyhow!("No files mentioned in commit message"))?;
121      if lines_added > 0 && lines_removed == 0 {
122        format!(
123          "Add {} to {}",
124          if lines_added == 1 {
125            "content"
126          } else {
127            "new content"
128          },
129          file
130        )
131      } else if lines_removed > 0 && lines_added == 0 {
132        format!("Remove content from {file}")
133      } else {
134        format!("Update {file}")
135      }
136    }
137    std::cmp::Ordering::Greater => format!("Update {} files", files_mentioned.len()),
138    std::cmp::Ordering::Less => "Update files".to_string()
139  };
140
141  // Ensure it fits within the length limit
142  let max_len = max_length.unwrap_or(72);
143  if message.len() > max_len {
144    Ok(message.chars().take(max_len - 3).collect::<String>() + "...")
145  } else {
146    Ok(message)
147  }
148}
149
150#[cfg(test)]
151mod tests {
152  use super::*;
153
154  /// C2: An OpenAI response with an empty `choices` array must not panic. The extracted
155  /// helper returns `None` (callers then produce a clean error) instead of indexing
156  /// `choices[0]`.
157  #[test]
158  fn test_first_function_call_arguments_empty_choices_no_panic() {
159    let json = serde_json::json!({
160      "id": "chatcmpl-test",
161      "choices": [],
162      "created": 0,
163      "model": "gpt-4.1",
164      "object": "chat.completion",
165      "usage": null
166    });
167    let response: async_openai::types::chat::CreateChatCompletionResponse =
168      serde_json::from_value(json).expect("should deserialize a response with empty choices");
169
170    assert!(first_function_call_arguments(&response).is_none());
171  }
172}