Skip to main content

llm_git/
api.rs

1use std::{path::Path, sync::OnceLock, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6   config::{CommitConfig, ResolvedApiMode},
7   error::{CommitGenError, Result},
8   templates,
9   tokens::TokenCounter,
10   types::{CommitSummary, CommitType, ConventionalAnalysis, ConventionalCommit, Scope},
11};
12
13/// Whether API tracing is enabled (`LLM_GIT_TRACE=1`).
14static TRACE_ENABLED: OnceLock<bool> = OnceLock::new();
15
16/// Check if API request tracing is enabled via `LLM_GIT_TRACE` env var.
17fn trace_enabled() -> bool {
18   *TRACE_ENABLED.get_or_init(|| std::env::var("LLM_GIT_TRACE").is_ok())
19}
20
21/// Send an HTTP request with timing instrumentation.
22///
23/// Measures TTFT (time to first byte / headers received) separately from total
24/// response time. Logs to stderr when `LLM_GIT_TRACE=1`.
25pub async fn timed_send(
26   request_builder: reqwest::RequestBuilder,
27   label: &str,
28   model: &str,
29) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
30   let trace = trace_enabled();
31   let start = std::time::Instant::now();
32
33   let response = request_builder
34      .send()
35      .await
36      .map_err(CommitGenError::HttpError)?;
37
38   let ttft = start.elapsed();
39   let status = response.status();
40   let content_length = response.content_length();
41
42   let body = response.text().await.map_err(CommitGenError::HttpError)?;
43   let total = start.elapsed();
44
45   if trace {
46      let size_info = content_length.map_or_else(
47         || format!("{}B", body.len()),
48         |cl| format!("{}B (content-length: {cl})", body.len()),
49      );
50      // Clear spinner line before printing (spinner writes \r to stdout)
51      if !crate::style::pipe_mode() {
52         print!("\r\x1b[K");
53         std::io::Write::flush(&mut std::io::stdout()).ok();
54      }
55      eprintln!(
56         "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
57          body={size_info}"
58      );
59   }
60
61   Ok((status, body))
62}
63
64// Prompts now loaded from config instead of compile-time constants
65
66/// Optional context information for commit analysis
67#[derive(Default)]
68pub struct AnalysisContext<'a> {
69   /// User-provided context
70   pub user_context:    Option<&'a str>,
71   /// Recent commits for style learning
72   pub recent_commits:  Option<&'a str>,
73   /// Common scopes for suggestions
74   pub common_scopes:   Option<&'a str>,
75   /// Project context (language, framework) for terminology
76   pub project_context: Option<&'a str>,
77   /// Debug output directory for saving raw I/O
78   pub debug_output:    Option<&'a Path>,
79   /// Prefix for debug output files to avoid collisions
80   pub debug_prefix:    Option<&'a str>,
81}
82
83/// Shared HTTP client, lazily initialized on first use.
84static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
85
86/// Get (or create) the shared HTTP client with timeouts from config.
87///
88/// The first call initializes the client with the given config's timeouts;
89/// subsequent calls reuse the same client regardless of config values.
90pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
91   CLIENT.get_or_init(|| {
92      reqwest::Client::builder()
93         .timeout(Duration::from_secs(config.request_timeout_secs))
94         .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
95         .build()
96         .expect("Failed to build HTTP client")
97   })
98}
99
100fn debug_filename(prefix: Option<&str>, name: &str) -> String {
101   match prefix {
102      Some(p) if !p.is_empty() => format!("{p}_{name}"),
103      _ => name.to_string(),
104   }
105}
106
107fn response_snippet(body: &str, limit: usize) -> String {
108   if body.is_empty() {
109      return "<empty response body>".to_string();
110   }
111   let mut snippet = body.trim().to_string();
112   if snippet.len() > limit {
113      snippet.truncate(limit);
114      snippet.push_str("...");
115   }
116   snippet
117}
118
119fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
120   let Some(dir) = debug_dir else {
121      return Ok(());
122   };
123
124   std::fs::create_dir_all(dir)?;
125   let path = dir.join(filename);
126   std::fs::write(&path, content)?;
127   Ok(())
128}
129
130fn anthropic_messages_url(base_url: &str) -> String {
131   let trimmed = base_url.trim_end_matches('/');
132   if trimmed.ends_with("/v1") {
133      format!("{trimmed}/messages")
134   } else {
135      format!("{trimmed}/v1/messages")
136   }
137}
138
139fn prompt_cache_control() -> PromptCacheControl {
140   PromptCacheControl { control_type: "ephemeral".to_string() }
141}
142
143fn anthropic_prompt_caching_enabled(config: &CommitConfig) -> bool {
144   config.api_base_url.to_lowercase().contains("anthropic.com")
145}
146
147fn append_anthropic_cache_beta_header(
148   request_builder: reqwest::RequestBuilder,
149   enable_cache: bool,
150) -> reqwest::RequestBuilder {
151   if enable_cache {
152      request_builder.header("anthropic-beta", "prompt-caching-2024-07-31")
153   } else {
154      request_builder
155   }
156}
157
158fn anthropic_text_content(text: String, cache: bool) -> AnthropicContent {
159   AnthropicContent {
160      content_type: "text".to_string(),
161      text,
162      cache_control: cache.then(prompt_cache_control),
163   }
164}
165
166fn anthropic_system_content(system_prompt: &str, cache: bool) -> Option<Vec<AnthropicContent>> {
167   if system_prompt.trim().is_empty() {
168      None
169   } else {
170      Some(vec![anthropic_text_content(system_prompt.to_string(), cache)])
171   }
172}
173
174fn cache_last_anthropic_tool(tools: &mut [AnthropicTool], cache: bool) {
175   if cache && let Some(last) = tools.last_mut() {
176      last.cache_control = Some(prompt_cache_control());
177   }
178}
179
180fn supports_openai_prompt_cache_key(config: &CommitConfig) -> bool {
181   config
182      .api_base_url
183      .to_lowercase()
184      .contains("api.openai.com")
185}
186
187/// Generate a deterministic cache key for `OpenAI` prompt-prefix routing.
188pub fn openai_prompt_cache_key(
189   config: &CommitConfig,
190   model_name: &str,
191   prompt_family: &str,
192   prompt_variant: &str,
193   system_prompt: &str,
194) -> Option<String> {
195   if system_prompt.trim().is_empty() || !supports_openai_prompt_cache_key(config) {
196      return None;
197   }
198
199   Some(format!("llm-git:v1:{model_name}:{prompt_family}:{prompt_variant}"))
200}
201
202fn extract_anthropic_content(
203   response_text: &str,
204   tool_name: &str,
205) -> Result<(Option<serde_json::Value>, String)> {
206   let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
207      CommitGenError::Other(format!(
208         "Failed to parse Anthropic response JSON: {e}. Response body: {}",
209         response_snippet(response_text, 500)
210      ))
211   })?;
212
213   let mut tool_input: Option<serde_json::Value> = None;
214   let mut text_parts = Vec::new();
215
216   if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
217      for item in content {
218         let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
219         match item_type {
220            "tool_use" => {
221               let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
222               if name == tool_name
223                  && let Some(input) = item.get("input")
224               {
225                  tool_input = Some(input.clone());
226               }
227            },
228            "text" => {
229               if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
230                  text_parts.push(text.to_string());
231               }
232            },
233            _ => {},
234         }
235      }
236   }
237
238   Ok((tool_input, text_parts.join("\n")))
239}
240
241#[derive(Debug, Serialize)]
242struct Message {
243   role:    String,
244   content: String,
245}
246
247#[derive(Debug, Serialize, Deserialize)]
248struct FunctionParameters {
249   #[serde(rename = "type")]
250   param_type: String,
251   properties: serde_json::Value,
252   required:   Vec<String>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256struct Function {
257   name:        String,
258   description: String,
259   parameters:  FunctionParameters,
260}
261
262#[derive(Debug, Serialize, Deserialize)]
263struct Tool {
264   #[serde(rename = "type")]
265   tool_type: String,
266   function:  Function,
267}
268
269#[derive(Debug, Serialize)]
270struct ApiRequest {
271   model:            String,
272   max_tokens:       u32,
273   temperature:      f32,
274   tools:            Vec<Tool>,
275   #[serde(skip_serializing_if = "Option::is_none")]
276   tool_choice:      Option<serde_json::Value>,
277   #[serde(skip_serializing_if = "Option::is_none")]
278   prompt_cache_key: Option<String>,
279   messages:         Vec<Message>,
280}
281
282#[derive(Debug, Serialize)]
283struct AnthropicRequest {
284   model:       String,
285   max_tokens:  u32,
286   temperature: f32,
287   #[serde(skip_serializing_if = "Option::is_none")]
288   system:      Option<Vec<AnthropicContent>>,
289   tools:       Vec<AnthropicTool>,
290   #[serde(skip_serializing_if = "Option::is_none")]
291   tool_choice: Option<AnthropicToolChoice>,
292   messages:    Vec<AnthropicMessage>,
293}
294
295#[derive(Debug, Clone, Serialize)]
296struct PromptCacheControl {
297   #[serde(rename = "type")]
298   control_type: String,
299}
300
301#[derive(Debug, Serialize)]
302struct AnthropicTool {
303   name:          String,
304   description:   String,
305   input_schema:  serde_json::Value,
306   #[serde(skip_serializing_if = "Option::is_none")]
307   cache_control: Option<PromptCacheControl>,
308}
309
310#[derive(Debug, Serialize)]
311struct AnthropicToolChoice {
312   #[serde(rename = "type")]
313   choice_type: String,
314   name:        String,
315}
316
317#[derive(Debug, Serialize)]
318struct AnthropicMessage {
319   role:    String,
320   content: Vec<AnthropicContent>,
321}
322
323#[derive(Debug, Clone, Serialize)]
324struct AnthropicContent {
325   #[serde(rename = "type")]
326   content_type:  String,
327   text:          String,
328   #[serde(skip_serializing_if = "Option::is_none")]
329   cache_control: Option<PromptCacheControl>,
330}
331
332#[derive(Debug, Deserialize)]
333struct ToolCall {
334   function: FunctionCall,
335}
336
337#[derive(Debug, Deserialize)]
338struct FunctionCall {
339   name:      String,
340   arguments: String,
341}
342
343#[derive(Debug, Deserialize)]
344struct Choice {
345   message: ResponseMessage,
346}
347
348#[derive(Debug, Deserialize)]
349struct ResponseMessage {
350   #[serde(default)]
351   tool_calls: Vec<ToolCall>,
352   #[serde(default)]
353   content:    Option<String>,
354}
355
356#[derive(Debug, Deserialize)]
357struct ApiResponse {
358   choices: Vec<Choice>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362struct SummaryOutput {
363   summary: String,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367struct FastCommitOutput {
368   #[serde(rename = "type")]
369   commit_type: String,
370   scope:       Option<String>,
371   summary:     String,
372   #[serde(default)]
373   details:     Vec<String>,
374}
375
376/// Retry an API call with exponential backoff
377pub async fn retry_api_call<T>(
378   config: &CommitConfig,
379   mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
380) -> Result<T> {
381   let mut attempt = 0;
382
383   loop {
384      attempt += 1;
385
386      match f().await {
387         Ok((false, Some(result))) => return Ok(result),
388         Ok((false, None)) => {
389            return Err(CommitGenError::Other("API call failed without result".to_string()));
390         },
391         Ok((true, _)) if attempt < config.max_retries => {
392            let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
393            eprintln!(
394               "{}",
395               crate::style::warning(&format!(
396                  "Retry {}/{} after {}ms...",
397                  attempt, config.max_retries, backoff_ms
398               ))
399            );
400            tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
401         },
402         Ok((true, _last_err)) => {
403            return Err(CommitGenError::ApiRetryExhausted {
404               retries: config.max_retries,
405               source:  Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
406            });
407         },
408         Err(e) => {
409            if attempt < config.max_retries {
410               let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
411               eprintln!(
412                  "{}",
413                  crate::style::warning(&format!(
414                     "Error: {} - Retry {}/{} after {}ms...",
415                     e, attempt, config.max_retries, backoff_ms
416                  ))
417               );
418               tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
419               continue;
420            }
421            return Err(e);
422         },
423      }
424   }
425}
426
427/// Format commit types from config into a rich description for the prompt
428/// Order is preserved from config (first = highest priority)
429pub fn format_types_description(config: &CommitConfig) -> String {
430   use std::fmt::Write;
431   let mut out = String::from("Check types in order (first match wins):\n\n");
432
433   for (name, tc) in &config.types {
434      let _ = writeln!(out, "**{name}**: {}", tc.description);
435      if !tc.diff_indicators.is_empty() {
436         let _ = writeln!(out, "  Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
437      }
438      if !tc.file_patterns.is_empty() {
439         let _ = writeln!(out, "  File patterns: {}", tc.file_patterns.join(", "));
440      }
441      for ex in &tc.examples {
442         let _ = writeln!(out, "  - {ex}");
443      }
444      if !tc.hint.is_empty() {
445         let _ = writeln!(out, "  Note: {}", tc.hint);
446      }
447      out.push('\n');
448   }
449
450   if !config.classifier_hint.is_empty() {
451      let _ = writeln!(out, "\n{}", config.classifier_hint);
452   }
453
454   out
455}
456
457/// Generate conventional commit analysis using OpenAI-compatible API
458pub async fn generate_conventional_analysis<'a>(
459   stat: &'a str,
460   diff: &'a str,
461   model_name: &'a str,
462   scope_candidates_str: &'a str,
463   ctx: &AnalysisContext<'a>,
464   config: &'a CommitConfig,
465) -> Result<ConventionalAnalysis> {
466   retry_api_call(config, async move || {
467      let client = get_client(config);
468
469      // Build type enum from config
470      let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
471
472      // Define the conventional analysis tool
473      let tool = Tool {
474         tool_type: "function".to_string(),
475         function:  Function {
476            name:        "create_conventional_analysis".to_string(),
477            description: "Analyze changes and classify as conventional commit with type, scope, \
478                          details, and metadata"
479               .to_string(),
480            parameters:  FunctionParameters {
481               param_type: "object".to_string(),
482               properties: serde_json::json!({
483                  "type": {
484                     "type": "string",
485                     "enum": type_enum,
486                     "description": "Commit type based on change classification"
487                  },
488                  "scope": {
489                     "type": "string",
490                     "description": "Optional scope (module/component). Omit if unclear or multi-component."
491                  },
492                  "details": {
493                     "type": "array",
494                     "description": "Array of 0-6 detail items with changelog metadata.",
495                     "items": {
496                        "type": "object",
497                        "properties": {
498                           "text": {
499                              "type": "string",
500                              "description": "Detail about change, starting with past-tense verb, ending with period"
501                           },
502                           "changelog_category": {
503                              "type": "string",
504                              "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
505                              "description": "Changelog category if user-visible. Omit for internal changes."
506                           },
507                           "user_visible": {
508                              "type": "boolean",
509                              "description": "True if this change affects users/API and should appear in changelog"
510                           }
511                        },
512                        "required": ["text", "user_visible"]
513                     }
514                  },
515                  "issue_refs": {
516                     "type": "array",
517                     "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
518                     "items": {
519                        "type": "string"
520                     }
521                  }
522               }),
523               required:   vec![
524                  "type".to_string(),
525                  "details".to_string(),
526                  "issue_refs".to_string(),
527               ],
528            },
529         },
530      };
531
532      let debug_dir = ctx.debug_output;
533      let debug_prefix = ctx.debug_prefix;
534      let mode = config.resolved_api_mode(model_name);
535
536      let response_text = match mode {
537         ResolvedApiMode::ChatCompletions => {
538            let types_desc = format_types_description(config);
539            let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
540               variant: &config.analysis_prompt_variant,
541               stat,
542               diff,
543               scope_candidates: scope_candidates_str,
544               recent_commits: ctx.recent_commits,
545               common_scopes: ctx.common_scopes,
546               types_description: Some(&types_desc),
547               project_context: ctx.project_context,
548            })?;
549
550            let user_content = if let Some(user_ctx) = ctx.user_context {
551               format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
552            } else {
553               parts.user
554            };
555
556            let prompt_cache_key = openai_prompt_cache_key(
557               config,
558               model_name,
559               "analysis",
560               &config.analysis_prompt_variant,
561               &parts.system,
562            );
563            let request = ApiRequest {
564               model:            model_name.to_string(),
565               max_tokens:       1000,
566               temperature:      config.temperature,
567               tools:            vec![tool],
568               tool_choice:      Some(
569                  serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
570               ),
571               prompt_cache_key,
572               messages:         vec![
573                  Message { role: "system".to_string(), content: parts.system },
574                  Message { role: "user".to_string(), content: user_content },
575               ],
576            };
577
578            if debug_dir.is_some() {
579               let request_json = serde_json::to_string_pretty(&request)?;
580               save_debug_output(
581                  debug_dir,
582                  &debug_filename(debug_prefix, "analysis_request.json"),
583                  &request_json,
584               )?;
585            }
586
587            let mut request_builder = client
588               .post(format!("{}/chat/completions", config.api_base_url))
589               .header("content-type", "application/json");
590
591            // Add Authorization header if API key is configured
592            if let Some(api_key) = &config.api_key {
593               request_builder =
594                  request_builder.header("Authorization", format!("Bearer {api_key}"));
595            }
596
597            let (status, response_text) =
598               timed_send(request_builder.json(&request), "analysis", model_name).await?;
599            if debug_dir.is_some() {
600               save_debug_output(
601                  debug_dir,
602                  &debug_filename(debug_prefix, "analysis_response.json"),
603                  &response_text,
604               )?;
605            }
606
607            // Retry on 5xx errors
608            if status.is_server_error() {
609               eprintln!(
610                  "{}",
611                  crate::style::error(&format!("Server error {status}: {response_text}"))
612               );
613               return Ok((true, None)); // Retry
614            }
615
616            if !status.is_success() {
617               return Err(CommitGenError::ApiError {
618                  status: status.as_u16(),
619                  body:   response_text,
620               });
621            }
622
623            response_text
624         },
625         ResolvedApiMode::AnthropicMessages => {
626            let types_desc = format_types_description(config);
627            let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
628               variant: &config.analysis_prompt_variant,
629               stat,
630               diff,
631               scope_candidates: scope_candidates_str,
632               recent_commits: ctx.recent_commits,
633               common_scopes: ctx.common_scopes,
634               types_description: Some(&types_desc),
635               project_context: ctx.project_context,
636            })?;
637
638            let user_content = if let Some(user_ctx) = ctx.user_context {
639               format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
640            } else {
641               parts.user
642            };
643
644            let prompt_caching = anthropic_prompt_caching_enabled(config);
645            let mut tools = vec![AnthropicTool {
646               name:         "create_conventional_analysis".to_string(),
647               description:  "Analyze changes and classify as conventional commit with type, \
648                              scope, details, and metadata"
649                  .to_string(),
650               input_schema: serde_json::json!({
651                     "type": "object",
652                     "properties": {
653                        "type": {
654                           "type": "string",
655                           "enum": type_enum,
656                           "description": "Commit type based on change classification"
657                        },
658                        "scope": {
659                           "type": "string",
660                           "description": "Optional scope (module/component). Omit if unclear or multi-component."
661                        },
662                        "details": {
663                           "type": "array",
664                           "description": "Array of 0-6 detail items with changelog metadata.",
665                           "items": {
666                              "type": "object",
667                              "properties": {
668                                 "text": {
669                                    "type": "string",
670                                    "description": "Detail about change, starting with past-tense verb, ending with period"
671                                 },
672                                 "changelog_category": {
673                                    "type": "string",
674                                    "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
675                                    "description": "Changelog category if user-visible. Omit for internal changes."
676                                 },
677                                 "user_visible": {
678                                    "type": "boolean",
679                                    "description": "True if this change affects users/API and should appear in changelog"
680                                 }
681                              },
682                              "required": ["text", "user_visible"]
683                           }
684                        },
685                        "issue_refs": {
686                           "type": "array",
687                           "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
688                           "items": {
689                              "type": "string"
690                           }
691                        }
692                     },
693                     "required": ["type", "details", "issue_refs"]
694                  }),
695               cache_control: None,
696            }];
697            cache_last_anthropic_tool(&mut tools, prompt_caching);
698
699            let request = AnthropicRequest {
700               model:       model_name.to_string(),
701               max_tokens:  1000,
702               temperature: config.temperature,
703               system:      anthropic_system_content(&parts.system, prompt_caching),
704               tools,
705               tool_choice: Some(AnthropicToolChoice {
706                  choice_type: "tool".to_string(),
707                  name:        "create_conventional_analysis".to_string(),
708               }),
709               messages:    vec![AnthropicMessage {
710                  role:    "user".to_string(),
711                  content: vec![anthropic_text_content(user_content, false)],
712               }],
713            };
714
715            if debug_dir.is_some() {
716               let request_json = serde_json::to_string_pretty(&request)?;
717               save_debug_output(
718                  debug_dir,
719                  &debug_filename(debug_prefix, "analysis_request.json"),
720                  &request_json,
721               )?;
722            }
723
724            let mut request_builder = append_anthropic_cache_beta_header(
725               client
726                  .post(anthropic_messages_url(&config.api_base_url))
727                  .header("content-type", "application/json")
728                  .header("anthropic-version", "2023-06-01"),
729               prompt_caching,
730            );
731
732            if let Some(api_key) = &config.api_key {
733               request_builder = request_builder.header("x-api-key", api_key);
734            }
735
736            let (status, response_text) =
737               timed_send(request_builder.json(&request), "analysis", model_name).await?;
738            if debug_dir.is_some() {
739               save_debug_output(
740                  debug_dir,
741                  &debug_filename(debug_prefix, "analysis_response.json"),
742                  &response_text,
743               )?;
744            }
745
746            if status.is_server_error() {
747               eprintln!(
748                  "{}",
749                  crate::style::error(&format!("Server error {status}: {response_text}"))
750               );
751               return Ok((true, None));
752            }
753
754            if !status.is_success() {
755               return Err(CommitGenError::ApiError {
756                  status: status.as_u16(),
757                  body:   response_text,
758               });
759            }
760
761            response_text
762         },
763      };
764
765      if response_text.trim().is_empty() {
766         crate::style::warn("Model returned empty response body for analysis; retrying.");
767         return Ok((true, None));
768      }
769
770      match mode {
771         ResolvedApiMode::ChatCompletions => {
772            let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
773               CommitGenError::Other(format!(
774                  "Failed to parse analysis response JSON: {e}. Response body: {}",
775                  response_snippet(&response_text, 500)
776               ))
777            })?;
778
779            if api_response.choices.is_empty() {
780               return Err(CommitGenError::Other(
781                  "API returned empty response for change analysis".to_string(),
782               ));
783            }
784
785            let message = &api_response.choices[0].message;
786
787            // Find the tool call in the response
788            if !message.tool_calls.is_empty() {
789               let tool_call = &message.tool_calls[0];
790               if tool_call
791                  .function
792                  .name
793                  .ends_with("create_conventional_analysis")
794               {
795                  let args = &tool_call.function.arguments;
796                  if args.is_empty() {
797                     crate::style::warn(
798                        "Model returned empty function arguments. Model may not support function \
799                         calling properly.",
800                     );
801                     return Err(CommitGenError::Other(
802                        "Model returned empty function arguments - try using a Claude model \
803                         (sonnet/opus/haiku)"
804                           .to_string(),
805                     ));
806                  }
807                  let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
808                     CommitGenError::Other(format!(
809                        "Failed to parse model response: {}. Response was: {}",
810                        e,
811                        args.chars().take(200).collect::<String>()
812                     ))
813                  })?;
814                  return Ok((false, Some(analysis)));
815               }
816            }
817
818            // Fallback: try to parse content as text
819            if let Some(content) = &message.content {
820               if content.trim().is_empty() {
821                  crate::style::warn("Model returned empty content for analysis; retrying.");
822                  return Ok((true, None));
823               }
824               let analysis: ConventionalAnalysis =
825                  serde_json::from_str(content.trim()).map_err(|e| {
826                     CommitGenError::Other(format!(
827                        "Failed to parse analysis content JSON: {e}. Content: {}",
828                        response_snippet(content, 500)
829                     ))
830                  })?;
831               return Ok((false, Some(analysis)));
832            }
833
834            Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
835         },
836         ResolvedApiMode::AnthropicMessages => {
837            let (tool_input, text_content) =
838               extract_anthropic_content(&response_text, "create_conventional_analysis")?;
839
840            if let Some(input) = tool_input {
841               let analysis: ConventionalAnalysis = serde_json::from_value(input).map_err(|e| {
842                  CommitGenError::Other(format!(
843                     "Failed to parse analysis tool input: {e}. Response body: {}",
844                     response_snippet(&response_text, 500)
845                  ))
846               })?;
847               return Ok((false, Some(analysis)));
848            }
849
850            if text_content.trim().is_empty() {
851               crate::style::warn("Model returned empty content for analysis; retrying.");
852               return Ok((true, None));
853            }
854
855            let analysis: ConventionalAnalysis = serde_json::from_str(text_content.trim())
856               .map_err(|e| {
857                  CommitGenError::Other(format!(
858                     "Failed to parse analysis content JSON: {e}. Content: {}",
859                     response_snippet(&text_content, 500)
860                  ))
861               })?;
862            Ok((false, Some(analysis)))
863         },
864      }
865   }).await
866}
867
868/// Strip conventional commit type prefix if LLM included it in summary.
869///
870/// Some models return the full format `feat(scope): summary` instead of just
871/// `summary`. This function removes the prefix to normalize the response.
872fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
873   let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
874   let prefix = format!("{commit_type}{scope_part}: ");
875
876   summary
877      .strip_prefix(&prefix)
878      .or_else(|| {
879         // Also try without scope in case model omitted it
880         let prefix_no_scope = format!("{commit_type}: ");
881         summary.strip_prefix(&prefix_no_scope)
882      })
883      .unwrap_or(summary)
884      .to_string()
885}
886
887/// Validate summary against requirements
888fn validate_summary_quality(
889   summary: &str,
890   commit_type: &str,
891   stat: &str,
892) -> std::result::Result<(), String> {
893   use crate::validation::is_past_tense_verb;
894
895   let first_word = summary
896      .split_whitespace()
897      .next()
898      .ok_or_else(|| "summary is empty".to_string())?;
899
900   let first_word_lower = first_word.to_lowercase();
901
902   // Check past-tense verb
903   if !is_past_tense_verb(&first_word_lower) {
904      return Err(format!(
905         "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
906      ));
907   }
908
909   // Check type repetition
910   if first_word_lower == commit_type {
911      return Err(format!("repeats commit type '{commit_type}' in summary"));
912   }
913
914   // Type-file mismatch heuristic
915   let file_exts: Vec<&str> = stat
916      .lines()
917      .filter_map(|line| {
918         let path = line.split('|').next()?.trim();
919         std::path::Path::new(path).extension()?.to_str()
920      })
921      .collect();
922
923   if !file_exts.is_empty() {
924      let total = file_exts.len();
925      let md_count = file_exts.iter().filter(|&&e| e == "md").count();
926
927      // If >80% markdown but not docs type, suggest docs
928      if md_count * 100 / total > 80 && commit_type != "docs" {
929         crate::style::warn(&format!(
930            "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
931            md_count * 100 / total,
932            commit_type
933         ));
934      }
935
936      // If no code files and type=feat/fix, warn
937      let code_exts = [
938         // Systems programming
939         "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
940         // JVM languages
941         "java", "kt", "kts", "scala", "groovy", "clj", "cljs", // .NET languages
942         "cs", "fs", "vb", // Web/scripting
943         "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", // Python ecosystem
944         "py", "pyx", "pxd", "pyi", // Ruby
945         "rb", "rake", "gemspec", // PHP
946         "php",     // Go
947         "go",      // Swift/Objective-C
948         "swift", "m", "mm",  // Lua
949         "lua", // Shell
950         "sh", "bash", "zsh", "fish", // Perl
951         "pl", "pm", // Haskell/ML family
952         "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
953         // Lisp family
954         "lisp", "cl", "el", "scm", "rkt", // Julia
955         "jl",  // R
956         "r", "R",    // Dart/Flutter
957         "dart", // Crystal
958         "cr",   // D
959         "d",    // Fortran
960         "f", "f90", "f95", "f03", "f08", // Ada
961         "ada", "adb", "ads", // Cobol
962         "cob", "cbl", // Assembly
963         "asm", "s", "S", // SQL (stored procs)
964         "sql", "plsql", // Prolog
965         "pl", "pro", // OCaml/ReasonML
966         "re", "rei", // Nix
967         "nix", // Terraform/HCL
968         "tf", "hcl",  // Solidity
969         "sol",  // Move
970         "move", // Cairo
971         "cairo",
972      ];
973      let code_count = file_exts
974         .iter()
975         .filter(|&&e| code_exts.contains(&e))
976         .count();
977      if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
978         crate::style::warn(&format!(
979            "Type mismatch: no code files changed but type is '{commit_type}'"
980         ));
981      }
982   }
983
984   Ok(())
985}
986
987/// Create commit summary using a smaller model focused on detail retention
988#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
989pub async fn generate_summary_from_analysis<'a>(
990   stat: &'a str,
991   commit_type: &'a str,
992   scope: Option<&'a str>,
993   details: &'a [String],
994   user_context: Option<&'a str>,
995   config: &'a CommitConfig,
996   debug_dir: Option<&'a Path>,
997   debug_prefix: Option<&'a str>,
998) -> Result<CommitSummary> {
999   let mut validation_attempt = 0;
1000   let max_validation_retries = 1;
1001   let mut last_failure_reason: Option<String> = None;
1002
1003   loop {
1004      let additional_constraint = if let Some(reason) = &last_failure_reason {
1005         format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1006      } else {
1007         String::new()
1008      };
1009
1010      let result = retry_api_call(config, async move || {
1011         // Pass details as plain sentences (no numbering - prevents model parroting)
1012         let bullet_points = details.join("\n");
1013
1014         let client = get_client(config);
1015
1016         let tool = Tool {
1017            tool_type: "function".to_string(),
1018            function:  Function {
1019               name:        "create_commit_summary".to_string(),
1020               description: "Compose a git commit summary line from detail statements".to_string(),
1021               parameters:  FunctionParameters {
1022                  param_type: "object".to_string(),
1023                  properties: serde_json::json!({
1024                     "summary": {
1025                        "type": "string",
1026                        "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1027                        "maxLength": config.summary_hard_limit
1028                     }
1029                  }),
1030                  required:   vec!["summary".to_string()],
1031               },
1032            },
1033         };
1034
1035         // Calculate guideline summary length accounting for "type(scope): " prefix
1036         let scope_str = scope.unwrap_or("");
1037         let prefix_len =
1038            commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 }; // "type: " or "type(scope): "
1039         let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
1040
1041         let mode = config.resolved_api_mode(&config.model);
1042
1043         let response_text = match mode {
1044            ResolvedApiMode::ChatCompletions => {
1045               let details_str = if bullet_points.is_empty() {
1046                  "None (no supporting detail points were generated)."
1047               } else {
1048                  bullet_points.as_str()
1049               };
1050
1051               let parts = templates::render_summary_prompt(
1052                  &config.summary_prompt_variant,
1053                  commit_type,
1054                  scope_str,
1055                  &max_summary_len.to_string(),
1056                  details_str,
1057                  stat.trim(),
1058                  user_context,
1059               )?;
1060
1061               let user_content = format!("{}{additional_constraint}", parts.user);
1062
1063               let prompt_cache_key = openai_prompt_cache_key(
1064                  config,
1065                  &config.model,
1066                  "summary",
1067                  &config.summary_prompt_variant,
1068                  &parts.system,
1069               );
1070               let request = ApiRequest {
1071                  model:            config.model.clone(),
1072                  max_tokens:       200,
1073                  temperature:      config.temperature,
1074                  tools:            vec![tool],
1075                  tool_choice:      Some(serde_json::json!({
1076                     "type": "function",
1077                     "function": { "name": "create_commit_summary" }
1078                  })),
1079                  prompt_cache_key,
1080                  messages:         vec![
1081                     Message { role: "system".to_string(), content: parts.system },
1082                     Message { role: "user".to_string(), content: user_content },
1083                  ],
1084               };
1085
1086               if debug_dir.is_some() {
1087                  let request_json = serde_json::to_string_pretty(&request)?;
1088                  save_debug_output(
1089                     debug_dir,
1090                     &debug_filename(debug_prefix, "summary_request.json"),
1091                     &request_json,
1092                  )?;
1093               }
1094
1095               let mut request_builder = client
1096                  .post(format!("{}/chat/completions", config.api_base_url))
1097                  .header("content-type", "application/json");
1098
1099               // Add Authorization header if API key is configured
1100               if let Some(api_key) = &config.api_key {
1101                  request_builder =
1102                     request_builder.header("Authorization", format!("Bearer {api_key}"));
1103               }
1104
1105               let (status, response_text) =
1106                  timed_send(request_builder.json(&request), "summary", &config.model).await?;
1107               if debug_dir.is_some() {
1108                  save_debug_output(
1109                     debug_dir,
1110                     &debug_filename(debug_prefix, "summary_response.json"),
1111                     &response_text,
1112                  )?;
1113               }
1114
1115               // Retry on 5xx errors
1116               if status.is_server_error() {
1117                  eprintln!(
1118                     "{}",
1119                     crate::style::error(&format!("Server error {status}: {response_text}"))
1120                  );
1121                  return Ok((true, None)); // Retry
1122               }
1123
1124               if !status.is_success() {
1125                  return Err(CommitGenError::ApiError {
1126                     status: status.as_u16(),
1127                     body:   response_text,
1128                  });
1129               }
1130
1131               response_text
1132            },
1133            ResolvedApiMode::AnthropicMessages => {
1134               let details_str = if bullet_points.is_empty() {
1135                  "None (no supporting detail points were generated)."
1136               } else {
1137                  bullet_points.as_str()
1138               };
1139
1140               let parts = templates::render_summary_prompt(
1141                  &config.summary_prompt_variant,
1142                  commit_type,
1143                  scope_str,
1144                  &max_summary_len.to_string(),
1145                  details_str,
1146                  stat.trim(),
1147                  user_context,
1148               )?;
1149
1150               let user_content = format!("{}{additional_constraint}", parts.user);
1151
1152               let prompt_caching = anthropic_prompt_caching_enabled(config);
1153               let mut tools = vec![AnthropicTool {
1154                  name:         "create_commit_summary".to_string(),
1155                  description:  "Compose a git commit summary line from detail statements"
1156                     .to_string(),
1157                  input_schema: serde_json::json!({
1158                        "type": "object",
1159                        "properties": {
1160                           "summary": {
1161                              "type": "string",
1162                              "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1163                              "maxLength": config.summary_hard_limit
1164                           }
1165                        },
1166                        "required": ["summary"]
1167                     }),
1168                  cache_control: None,
1169               }];
1170               cache_last_anthropic_tool(&mut tools, prompt_caching);
1171
1172               let request = AnthropicRequest {
1173                  model:       config.model.clone(),
1174                  max_tokens:  200,
1175                  temperature: config.temperature,
1176                  system:      anthropic_system_content(&parts.system, prompt_caching),
1177                  tools,
1178                  tool_choice: Some(AnthropicToolChoice {
1179                     choice_type: "tool".to_string(),
1180                     name:        "create_commit_summary".to_string(),
1181                  }),
1182                  messages:    vec![AnthropicMessage {
1183                     role:    "user".to_string(),
1184                     content: vec![anthropic_text_content(user_content, false)],
1185                  }],
1186               };
1187
1188               if debug_dir.is_some() {
1189                  let request_json = serde_json::to_string_pretty(&request)?;
1190                  save_debug_output(
1191                     debug_dir,
1192                     &debug_filename(debug_prefix, "summary_request.json"),
1193                     &request_json,
1194                  )?;
1195               }
1196
1197               let mut request_builder = append_anthropic_cache_beta_header(
1198                  client
1199                     .post(anthropic_messages_url(&config.api_base_url))
1200                     .header("content-type", "application/json")
1201                     .header("anthropic-version", "2023-06-01"),
1202                  prompt_caching,
1203               );
1204
1205               if let Some(api_key) = &config.api_key {
1206                  request_builder = request_builder.header("x-api-key", api_key);
1207               }
1208
1209               let (status, response_text) =
1210                  timed_send(request_builder.json(&request), "summary", &config.model).await?;
1211               if debug_dir.is_some() {
1212                  save_debug_output(
1213                     debug_dir,
1214                     &debug_filename(debug_prefix, "summary_response.json"),
1215                     &response_text,
1216                  )?;
1217               }
1218
1219               // Retry on 5xx errors
1220               if status.is_server_error() {
1221                  eprintln!(
1222                     "{}",
1223                     crate::style::error(&format!("Server error {status}: {response_text}"))
1224                  );
1225                  return Ok((true, None)); // Retry
1226               }
1227
1228               if !status.is_success() {
1229                  return Err(CommitGenError::ApiError {
1230                     status: status.as_u16(),
1231                     body:   response_text,
1232                  });
1233               }
1234
1235               response_text
1236            },
1237         };
1238
1239         if response_text.trim().is_empty() {
1240            crate::style::warn("Model returned empty response body for summary; retrying.");
1241            return Ok((true, None));
1242         }
1243
1244         match mode {
1245            ResolvedApiMode::ChatCompletions => {
1246               let api_response: ApiResponse =
1247                  serde_json::from_str(&response_text).map_err(|e| {
1248                     CommitGenError::Other(format!(
1249                        "Failed to parse summary response JSON: {e}. Response body: {}",
1250                        response_snippet(&response_text, 500)
1251                     ))
1252                  })?;
1253
1254               if api_response.choices.is_empty() {
1255                  return Err(CommitGenError::Other(
1256                     "Summary creation response was empty".to_string(),
1257                  ));
1258               }
1259
1260               let message_choice = &api_response.choices[0].message;
1261
1262               if !message_choice.tool_calls.is_empty() {
1263                  let tool_call = &message_choice.tool_calls[0];
1264                  if tool_call.function.name.ends_with("create_commit_summary") {
1265                     let args = &tool_call.function.arguments;
1266                     if args.is_empty() {
1267                        crate::style::warn(
1268                           "Model returned empty function arguments for summary. Model may not \
1269                            support function calling.",
1270                        );
1271                        return Err(CommitGenError::Other(
1272                           "Model returned empty summary arguments - try using a Claude model \
1273                            (sonnet/opus/haiku)"
1274                              .to_string(),
1275                        ));
1276                     }
1277                     let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
1278                        CommitGenError::Other(format!(
1279                           "Failed to parse summary response: {}. Response was: {}",
1280                           e,
1281                           args.chars().take(200).collect::<String>()
1282                        ))
1283                     })?;
1284                     // Strip type prefix if LLM included it (e.g., "feat(scope): summary" ->
1285                     // "summary")
1286                     let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1287                     return Ok((
1288                        false,
1289                        Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1290                     ));
1291                  }
1292               }
1293
1294               if let Some(content) = &message_choice.content {
1295                  if content.trim().is_empty() {
1296                     crate::style::warn("Model returned empty content for summary; retrying.");
1297                     return Ok((true, None));
1298                  }
1299                  // Try JSON first, fall back to plain text (for models without function calling)
1300                  let trimmed = content.trim();
1301                  let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1302                     Ok(summary) => summary.summary,
1303                     Err(e) => {
1304                        // Only use plain text if it doesn't look like JSON
1305                        if trimmed.starts_with('{') {
1306                           return Err(CommitGenError::Other(format!(
1307                              "Failed to parse summary JSON: {e}. Content: {}",
1308                              response_snippet(trimmed, 500)
1309                           )));
1310                        }
1311                        // Model returned plain text instead of JSON - use it directly
1312                        trimmed.to_string()
1313                     },
1314                  };
1315                  // Strip type prefix if LLM included it
1316                  let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1317                  return Ok((
1318                     false,
1319                     Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1320                  ));
1321               }
1322
1323               Err(CommitGenError::Other(
1324                  "No summary found in summary creation response".to_string(),
1325               ))
1326            },
1327            ResolvedApiMode::AnthropicMessages => {
1328               let (tool_input, text_content) =
1329                  extract_anthropic_content(&response_text, "create_commit_summary")?;
1330
1331               if let Some(input) = tool_input {
1332                  let summary: SummaryOutput = serde_json::from_value(input).map_err(|e| {
1333                     CommitGenError::Other(format!(
1334                        "Failed to parse summary tool input: {e}. Response body: {}",
1335                        response_snippet(&response_text, 500)
1336                     ))
1337                  })?;
1338                  let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1339                  return Ok((
1340                     false,
1341                     Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1342                  ));
1343               }
1344
1345               if text_content.trim().is_empty() {
1346                  crate::style::warn("Model returned empty content for summary; retrying.");
1347                  return Ok((true, None));
1348               }
1349
1350               // Try JSON first, fall back to plain text (for models without function calling)
1351               let trimmed = text_content.trim();
1352               let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1353                  Ok(summary) => summary.summary,
1354                  Err(e) => {
1355                     // Only use plain text if it doesn't look like JSON
1356                     if trimmed.starts_with('{') {
1357                        return Err(CommitGenError::Other(format!(
1358                           "Failed to parse summary JSON: {e}. Content: {}",
1359                           response_snippet(trimmed, 500)
1360                        )));
1361                     }
1362                     // Model returned plain text instead of JSON - use it directly
1363                     trimmed.to_string()
1364                  },
1365               };
1366               let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1367               Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)))
1368            },
1369         }
1370      }).await;
1371
1372      match result {
1373         Ok(summary) => {
1374            // Validate quality
1375            match validate_summary_quality(summary.as_str(), commit_type, stat) {
1376               Ok(()) => return Ok(summary),
1377               Err(reason) if validation_attempt < max_validation_retries => {
1378                  crate::style::warn(&format!(
1379                     "Validation failed (attempt {}/{}): {}",
1380                     validation_attempt + 1,
1381                     max_validation_retries + 1,
1382                     reason
1383                  ));
1384                  last_failure_reason = Some(reason);
1385                  validation_attempt += 1;
1386                  // Retry with constraint
1387               },
1388               Err(reason) => {
1389                  crate::style::warn(&format!(
1390                     "Validation failed after {} retries: {}. Using fallback.",
1391                     max_validation_retries + 1,
1392                     reason
1393                  ));
1394                  // Fallback: use first detail or heuristic
1395                  return Ok(fallback_from_details_or_summary(
1396                     details,
1397                     summary.as_str(),
1398                     commit_type,
1399                     config,
1400                  ));
1401               },
1402            }
1403         },
1404         Err(e) => return Err(e),
1405      }
1406   }
1407}
1408
1409/// Fallback when validation fails: use first detail, strip type word if present
1410fn fallback_from_details_or_summary(
1411   details: &[String],
1412   invalid_summary: &str,
1413   commit_type: &str,
1414   config: &CommitConfig,
1415) -> CommitSummary {
1416   let candidate = if let Some(first_detail) = details.first() {
1417      // Use first detail line, strip type word
1418      let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1419
1420      // Remove type word if present at start
1421      let type_word_variants =
1422         [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1423      for variant in &type_word_variants {
1424         if cleaned
1425            .to_lowercase()
1426            .starts_with(&format!("{} ", variant.to_lowercase()))
1427         {
1428            cleaned = cleaned[variant.len()..].trim().to_string();
1429            break;
1430         }
1431      }
1432
1433      cleaned
1434   } else {
1435      // No details, try to fix invalid summary
1436      let mut cleaned = invalid_summary
1437         .split_whitespace()
1438         .skip(1) // Remove first word (invalid verb)
1439         .collect::<Vec<_>>()
1440         .join(" ");
1441
1442      if cleaned.is_empty() {
1443         cleaned = fallback_summary("", details, commit_type, config)
1444            .as_str()
1445            .to_string();
1446      }
1447
1448      cleaned
1449   };
1450
1451   // Ensure valid past-tense verb prefix
1452   let with_verb = if candidate
1453      .split_whitespace()
1454      .next()
1455      .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
1456   {
1457      candidate
1458   } else {
1459      let verb = match commit_type {
1460         "feat" => "added",
1461         "fix" => "fixed",
1462         "refactor" => "restructured",
1463         "docs" => "documented",
1464         "test" => "tested",
1465         "perf" => "optimized",
1466         "build" | "ci" | "chore" => "updated",
1467         "style" => "formatted",
1468         "revert" => "reverted",
1469         _ => "changed",
1470      };
1471      format!("{verb} {candidate}")
1472   };
1473
1474   CommitSummary::new(with_verb, config.summary_hard_limit)
1475      .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1476}
1477
1478/// Provide a deterministic fallback summary if model generation fails
1479pub fn fallback_summary(
1480   stat: &str,
1481   details: &[String],
1482   commit_type: &str,
1483   config: &CommitConfig,
1484) -> CommitSummary {
1485   let mut candidate = if let Some(first) = details.first() {
1486      first.trim().trim_end_matches('.').to_string()
1487   } else {
1488      let primary_line = stat
1489         .lines()
1490         .map(str::trim)
1491         .find(|line| !line.is_empty())
1492         .unwrap_or("files");
1493
1494      let subject = primary_line
1495         .split('|')
1496         .next()
1497         .map(str::trim)
1498         .filter(|s| !s.is_empty())
1499         .unwrap_or("files");
1500
1501      if subject.eq_ignore_ascii_case("files") {
1502         "Updated files".to_string()
1503      } else {
1504         format!("Updated {subject}")
1505      }
1506   };
1507
1508   candidate = candidate
1509      .replace(['\n', '\r'], " ")
1510      .split_whitespace()
1511      .collect::<Vec<_>>()
1512      .join(" ")
1513      .trim()
1514      .trim_end_matches('.')
1515      .trim_end_matches(';')
1516      .trim_end_matches(':')
1517      .to_string();
1518
1519   if candidate.is_empty() {
1520      candidate = "Updated files".to_string();
1521   }
1522
1523   // Truncate to conservative length (50 chars) since we don't know the scope yet
1524   // post_process_commit_message will truncate further if needed
1525   const CONSERVATIVE_MAX: usize = 50;
1526   while candidate.len() > CONSERVATIVE_MAX {
1527      if let Some(pos) = candidate.rfind(' ') {
1528         candidate.truncate(pos);
1529         candidate = candidate.trim_end_matches(',').trim().to_string();
1530      } else {
1531         candidate.truncate(CONSERVATIVE_MAX);
1532         break;
1533      }
1534   }
1535
1536   // Ensure no trailing period (conventional commits style)
1537   candidate = candidate.trim_end_matches('.').to_string();
1538
1539   // If the candidate ended up identical to the commit type, replace with a safer
1540   // default
1541   if candidate
1542      .split_whitespace()
1543      .next()
1544      .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1545   {
1546      candidate = match commit_type {
1547         "refactor" => "restructured change".to_string(),
1548         "feat" => "added functionality".to_string(),
1549         "fix" => "fixed issue".to_string(),
1550         "docs" => "documented updates".to_string(),
1551         "test" => "tested changes".to_string(),
1552         "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1553         "perf" => "optimized performance".to_string(),
1554         "revert" => "reverted previous commit".to_string(),
1555         _ => "updated files".to_string(),
1556      };
1557   }
1558
1559   // Unwrap is safe: fallback_summary guarantees non-empty string ≤50 chars (<
1560   // config limit)
1561   CommitSummary::new(candidate, config.summary_hard_limit)
1562      .expect("fallback summary should always be valid")
1563}
1564
1565/// Generate conventional commit analysis, using map-reduce for large diffs
1566///
1567/// This is the main entry point for analysis. It automatically routes to
1568/// map-reduce when the diff exceeds the configured token threshold.
1569pub async fn generate_analysis_with_map_reduce<'a>(
1570   stat: &'a str,
1571   diff: &'a str,
1572   model_name: &'a str,
1573   scope_candidates_str: &'a str,
1574   ctx: &AnalysisContext<'a>,
1575   config: &'a CommitConfig,
1576   counter: &TokenCounter,
1577) -> Result<ConventionalAnalysis> {
1578   use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1579
1580   if should_use_map_reduce(diff, config, counter) {
1581      crate::style::print_info(&format!(
1582         "Large diff detected ({} tokens), using map-reduce...",
1583         counter.count_sync(diff)
1584      ));
1585      run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1586   } else {
1587      generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1588         .await
1589   }
1590}
1591
1592/// Generate a complete commit in a single API call (fast mode).
1593///
1594/// Returns a `ConventionalCommit` directly — no separate summary phase.
1595pub async fn generate_fast_commit(
1596   stat: &str,
1597   diff: &str,
1598   model_name: &str,
1599   scope_candidates_str: &str,
1600   user_context: Option<&str>,
1601   config: &CommitConfig,
1602   debug_dir: Option<&Path>,
1603) -> Result<ConventionalCommit> {
1604   retry_api_call(config, async move || {
1605      let client = get_client(config);
1606
1607      // Build type enum from config
1608      let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1609
1610      let parts = templates::render_fast_prompt(&templates::FastPromptParams {
1611         variant:          "default",
1612         stat,
1613         diff,
1614         scope_candidates: scope_candidates_str,
1615         user_context,
1616      })?;
1617
1618      let mode = config.resolved_api_mode(model_name);
1619
1620      let response_text = match mode {
1621         ResolvedApiMode::ChatCompletions => {
1622            let tool = Tool {
1623               tool_type: "function".to_string(),
1624               function:  Function {
1625                  name:        "create_fast_commit".to_string(),
1626                  description: "Generate a conventional commit from the given diff".to_string(),
1627                  parameters:  FunctionParameters {
1628                     param_type: "object".to_string(),
1629                     properties: serde_json::json!({
1630                        "type": {
1631                           "type": "string",
1632                           "enum": type_enum,
1633                           "description": "Conventional commit type"
1634                        },
1635                        "scope": {
1636                           "type": "string",
1637                           "description": "Optional scope. Omit if unclear or cross-cutting."
1638                        },
1639                        "summary": {
1640                           "type": "string",
1641                           "description": "≤72 char past-tense summary, no type prefix, no trailing period"
1642                        },
1643                        "details": {
1644                           "type": "array",
1645                           "items": { "type": "string" },
1646                           "description": "0-3 past-tense detail sentences ending with period"
1647                        }
1648                     }),
1649                     required:   vec![
1650                        "type".to_string(),
1651                        "summary".to_string(),
1652                        "details".to_string(),
1653                     ],
1654                  },
1655               },
1656            };
1657
1658            let prompt_cache_key = openai_prompt_cache_key(
1659               config,
1660               model_name,
1661               "fast",
1662               "default",
1663               &parts.system,
1664            );
1665            let request = ApiRequest {
1666               model:            model_name.to_string(),
1667               max_tokens:       500,
1668               temperature:      config.temperature,
1669               tools:            vec![tool],
1670               tool_choice:      Some(
1671                  serde_json::json!({ "type": "function", "function": { "name": "create_fast_commit" } }),
1672               ),
1673               prompt_cache_key,
1674               messages:         vec![
1675                  Message { role: "system".to_string(), content: parts.system },
1676                  Message { role: "user".to_string(), content: parts.user },
1677               ],
1678            };
1679
1680            if debug_dir.is_some() {
1681               let request_json = serde_json::to_string_pretty(&request)?;
1682               save_debug_output(debug_dir, &debug_filename(None, "fast_request.json"), &request_json)?;
1683            }
1684
1685            let mut request_builder = client
1686               .post(format!("{}/chat/completions", config.api_base_url))
1687               .header("content-type", "application/json");
1688
1689            if let Some(api_key) = &config.api_key {
1690               request_builder =
1691                  request_builder.header("Authorization", format!("Bearer {api_key}"));
1692            }
1693
1694            let (status, response_text) =
1695               timed_send(request_builder.json(&request), "fast", model_name).await?;
1696            if debug_dir.is_some() {
1697               save_debug_output(
1698                  debug_dir,
1699                  &debug_filename(None, "fast_response.json"),
1700                  &response_text,
1701               )?;
1702            }
1703
1704            if status.is_server_error() {
1705               eprintln!(
1706                  "{}",
1707                  crate::style::error(&format!("Server error {status}: {response_text}"))
1708               );
1709               return Ok((true, None));
1710            }
1711
1712            if !status.is_success() {
1713               return Err(CommitGenError::ApiError {
1714                  status: status.as_u16(),
1715                  body:   response_text,
1716               });
1717            }
1718
1719            response_text
1720         },
1721         ResolvedApiMode::AnthropicMessages => {
1722            let prompt_caching = anthropic_prompt_caching_enabled(config);
1723            let mut tools = vec![AnthropicTool {
1724               name:         "create_fast_commit".to_string(),
1725               description:  "Generate a conventional commit from the given diff".to_string(),
1726               input_schema: serde_json::json!({
1727                  "type": "object",
1728                  "properties": {
1729                     "type": {
1730                        "type": "string",
1731                        "enum": type_enum,
1732                        "description": "Conventional commit type"
1733                     },
1734                     "scope": {
1735                        "type": "string",
1736                        "description": "Optional scope. Omit if unclear or cross-cutting."
1737                     },
1738                     "summary": {
1739                        "type": "string",
1740                        "description": "≤72 char past-tense summary, no type prefix, no trailing period"
1741                     },
1742                     "details": {
1743                        "type": "array",
1744                        "items": { "type": "string" },
1745                        "description": "0-3 past-tense detail sentences ending with period"
1746                     }
1747                  },
1748                  "required": ["type", "summary", "details"]
1749               }),
1750               cache_control: None,
1751            }];
1752            cache_last_anthropic_tool(&mut tools, prompt_caching);
1753
1754            let request = AnthropicRequest {
1755               model:       model_name.to_string(),
1756               max_tokens:  500,
1757               temperature: config.temperature,
1758               system:      anthropic_system_content(&parts.system, prompt_caching),
1759               tools,
1760               tool_choice: Some(AnthropicToolChoice {
1761                  choice_type: "tool".to_string(),
1762                  name:        "create_fast_commit".to_string(),
1763               }),
1764               messages:    vec![AnthropicMessage {
1765                  role:    "user".to_string(),
1766                  content: vec![anthropic_text_content(parts.user, false)],
1767               }],
1768            };
1769
1770            if debug_dir.is_some() {
1771               let request_json = serde_json::to_string_pretty(&request)?;
1772               save_debug_output(debug_dir, &debug_filename(None, "fast_request.json"), &request_json)?;
1773            }
1774
1775            let mut request_builder = append_anthropic_cache_beta_header(
1776               client
1777                  .post(anthropic_messages_url(&config.api_base_url))
1778                  .header("content-type", "application/json")
1779                  .header("anthropic-version", "2023-06-01"),
1780               prompt_caching,
1781            );
1782
1783            if let Some(api_key) = &config.api_key {
1784               request_builder = request_builder.header("x-api-key", api_key);
1785            }
1786
1787            let (status, response_text) =
1788               timed_send(request_builder.json(&request), "fast", model_name).await?;
1789            if debug_dir.is_some() {
1790               save_debug_output(
1791                  debug_dir,
1792                  &debug_filename(None, "fast_response.json"),
1793                  &response_text,
1794               )?;
1795            }
1796
1797            if status.is_server_error() {
1798               eprintln!(
1799                  "{}",
1800                  crate::style::error(&format!("Server error {status}: {response_text}"))
1801               );
1802               return Ok((true, None));
1803            }
1804
1805            if !status.is_success() {
1806               return Err(CommitGenError::ApiError {
1807                  status: status.as_u16(),
1808                  body:   response_text,
1809               });
1810            }
1811
1812            response_text
1813         },
1814      };
1815
1816      if response_text.trim().is_empty() {
1817         crate::style::warn("Model returned empty response body for fast commit; retrying.");
1818         return Ok((true, None));
1819      }
1820
1821      match mode {
1822         ResolvedApiMode::ChatCompletions => {
1823            let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
1824               CommitGenError::Other(format!(
1825                  "Failed to parse fast commit response JSON: {e}. Response body: {}",
1826                  response_snippet(&response_text, 500)
1827               ))
1828            })?;
1829
1830            if api_response.choices.is_empty() {
1831               return Err(CommitGenError::Other(
1832                  "API returned empty response for fast commit".to_string(),
1833               ));
1834            }
1835
1836            let message = &api_response.choices[0].message;
1837
1838            if !message.tool_calls.is_empty() {
1839               let tool_call = &message.tool_calls[0];
1840               if tool_call.function.name.ends_with("create_fast_commit") {
1841                  let args = &tool_call.function.arguments;
1842                  if args.is_empty() {
1843                     crate::style::warn(
1844                        "Model returned empty function arguments. Model may not support function \
1845                         calling properly.",
1846                     );
1847                     return Err(CommitGenError::Other(
1848                        "Model returned empty function arguments - try using a Claude model \
1849                         (sonnet/opus/haiku)"
1850                           .to_string(),
1851                     ));
1852                  }
1853                  let output: FastCommitOutput = serde_json::from_str(args).map_err(|e| {
1854                     CommitGenError::Other(format!(
1855                        "Failed to parse fast commit response: {}. Response was: {}",
1856                        e,
1857                        args.chars().take(200).collect::<String>()
1858                     ))
1859                  })?;
1860                  let commit = build_fast_commit(output, config)?;
1861                  return Ok((false, Some(commit)));
1862               }
1863            }
1864
1865            if let Some(content) = &message.content {
1866               if content.trim().is_empty() {
1867                  crate::style::warn("Model returned empty content for fast commit; retrying.");
1868                  return Ok((true, None));
1869               }
1870               let output: FastCommitOutput =
1871                  serde_json::from_str(content.trim()).map_err(|e| {
1872                     CommitGenError::Other(format!(
1873                        "Failed to parse fast commit content JSON: {e}. Content: {}",
1874                        response_snippet(content, 500)
1875                     ))
1876                  })?;
1877               let commit = build_fast_commit(output, config)?;
1878               return Ok((false, Some(commit)));
1879            }
1880
1881            Err(CommitGenError::Other("No fast commit found in API response".to_string()))
1882         },
1883         ResolvedApiMode::AnthropicMessages => {
1884            let (tool_input, text_content) =
1885               extract_anthropic_content(&response_text, "create_fast_commit")?;
1886
1887            if let Some(input) = tool_input {
1888               let output: FastCommitOutput = serde_json::from_value(input).map_err(|e| {
1889                  CommitGenError::Other(format!(
1890                     "Failed to parse fast commit tool input: {e}. Response body: {}",
1891                     response_snippet(&response_text, 500)
1892                  ))
1893               })?;
1894               let commit = build_fast_commit(output, config)?;
1895               return Ok((false, Some(commit)));
1896            }
1897
1898            if text_content.trim().is_empty() {
1899               crate::style::warn("Model returned empty content for fast commit; retrying.");
1900               return Ok((true, None));
1901            }
1902
1903            let output: FastCommitOutput = serde_json::from_str(text_content.trim())
1904               .map_err(|e| {
1905                  CommitGenError::Other(format!(
1906                     "Failed to parse fast commit content JSON: {e}. Content: {}",
1907                     response_snippet(&text_content, 500)
1908                  ))
1909               })?;
1910            let commit = build_fast_commit(output, config)?;
1911            Ok((false, Some(commit)))
1912         },
1913      }
1914   })
1915   .await
1916}
1917
1918/// Convert a `FastCommitOutput` into a validated `ConventionalCommit`.
1919fn build_fast_commit(
1920   output: FastCommitOutput,
1921   config: &CommitConfig,
1922) -> Result<ConventionalCommit> {
1923   let commit_type = CommitType::new(&output.commit_type)?;
1924   let scope = output.scope.as_deref().map(Scope::new).transpose()?;
1925   let summary = CommitSummary::new(&output.summary, config.summary_hard_limit)?;
1926   Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
1927}
1928#[cfg(test)]
1929mod tests {
1930   use super::*;
1931   use crate::config::CommitConfig;
1932
1933   #[test]
1934   fn test_validate_summary_quality_valid() {
1935      let stat = "src/main.rs | 10 +++++++---\n";
1936      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
1937      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
1938      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
1939   }
1940
1941   #[test]
1942   fn test_validate_summary_quality_invalid_verb() {
1943      let stat = "src/main.rs | 10 +++++++---\n";
1944      let result = validate_summary_quality("adding new feature", "feat", stat);
1945      assert!(result.is_err());
1946      assert!(result.unwrap_err().contains("past-tense verb"));
1947   }
1948
1949   #[test]
1950   fn test_validate_summary_quality_type_repetition() {
1951      let stat = "src/main.rs | 10 +++++++---\n";
1952      // "feat" is not a past-tense verb so it should fail on verb check first
1953      let result = validate_summary_quality("feat new feature", "feat", stat);
1954      assert!(result.is_err());
1955      assert!(result.unwrap_err().contains("past-tense verb"));
1956
1957      // "fixed" is past-tense but repeats "fix" type
1958      let result = validate_summary_quality("fix bug", "fix", stat);
1959      assert!(result.is_err());
1960      // "fix" is not in PAST_TENSE_VERBS, so fails on verb check
1961      assert!(result.unwrap_err().contains("past-tense verb"));
1962   }
1963
1964   #[test]
1965   fn test_validate_summary_quality_empty() {
1966      let stat = "src/main.rs | 10 +++++++---\n";
1967      let result = validate_summary_quality("", "feat", stat);
1968      assert!(result.is_err());
1969      assert!(result.unwrap_err().contains("empty"));
1970   }
1971
1972   #[test]
1973   fn test_validate_summary_quality_markdown_type_mismatch() {
1974      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
1975      // Should warn but not fail
1976      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
1977   }
1978
1979   #[test]
1980   fn test_validate_summary_quality_no_code_files() {
1981      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
1982      // Should warn but not fail
1983      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
1984   }
1985
1986   #[test]
1987   fn test_fallback_from_details_with_first_detail() {
1988      let config = CommitConfig::default();
1989      let details = vec![
1990         "Added authentication middleware.".to_string(),
1991         "Updated error handling.".to_string(),
1992      ];
1993      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
1994      // Capital A preserved from detail
1995      assert_eq!(result.as_str(), "Added authentication middleware");
1996   }
1997
1998   #[test]
1999   fn test_fallback_from_details_strips_type_word() {
2000      let config = CommitConfig::default();
2001      let details = vec!["Featuring new oauth flow.".to_string()];
2002      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2003      // Should strip "Featuring" (present participle, not past tense) and add valid
2004      // verb
2005      assert!(result.as_str().starts_with("added"));
2006   }
2007
2008   #[test]
2009   fn test_fallback_from_details_no_details() {
2010      let config = CommitConfig::default();
2011      let details: Vec<String> = vec![];
2012      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2013      // Should use rest of summary or fallback
2014      assert!(result.as_str().starts_with("added"));
2015   }
2016
2017   #[test]
2018   fn test_fallback_from_details_adds_verb() {
2019      let config = CommitConfig::default();
2020      let details = vec!["configuration for oauth".to_string()];
2021      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2022      assert_eq!(result.as_str(), "added configuration for oauth");
2023   }
2024
2025   #[test]
2026   fn test_fallback_from_details_preserves_existing_verb() {
2027      let config = CommitConfig::default();
2028      let details = vec!["fixed authentication bug".to_string()];
2029      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2030      assert_eq!(result.as_str(), "fixed authentication bug");
2031   }
2032
2033   #[test]
2034   fn test_fallback_from_details_type_specific_verbs() {
2035      let config = CommitConfig::default();
2036      let details = vec!["module structure".to_string()];
2037
2038      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2039      assert_eq!(result.as_str(), "restructured module structure");
2040
2041      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2042      assert_eq!(result.as_str(), "documented module structure");
2043
2044      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2045      assert_eq!(result.as_str(), "tested module structure");
2046
2047      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2048      assert_eq!(result.as_str(), "optimized module structure");
2049   }
2050
2051   #[test]
2052   fn test_fallback_summary_with_stat() {
2053      let config = CommitConfig::default();
2054      let stat = "src/main.rs | 10 +++++++---\n";
2055      let details = vec![];
2056      let result = fallback_summary(stat, &details, "feat", &config);
2057      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2058   }
2059
2060   #[test]
2061   fn test_fallback_summary_with_details() {
2062      let config = CommitConfig::default();
2063      let stat = "";
2064      let details = vec!["First detail here.".to_string()];
2065      let result = fallback_summary(stat, &details, "feat", &config);
2066      // Capital F preserved
2067      assert_eq!(result.as_str(), "First detail here");
2068   }
2069
2070   #[test]
2071   fn test_fallback_summary_no_stat_no_details() {
2072      let config = CommitConfig::default();
2073      let result = fallback_summary("", &[], "feat", &config);
2074      // Fallback returns "Updated files" when no stat/details
2075      assert_eq!(result.as_str(), "Updated files");
2076   }
2077
2078   #[test]
2079   fn test_fallback_summary_type_word_overlap() {
2080      let config = CommitConfig::default();
2081      let details = vec!["refactor was performed".to_string()];
2082      let result = fallback_summary("", &details, "refactor", &config);
2083      // Should replace "refactor" with type-specific verb
2084      assert_eq!(result.as_str(), "restructured change");
2085   }
2086
2087   #[test]
2088   fn test_fallback_summary_length_limit() {
2089      let config = CommitConfig::default();
2090      let long_detail = "a ".repeat(100); // 200 chars
2091      let details = vec![long_detail.trim().to_string()];
2092      let result = fallback_summary("", &details, "feat", &config);
2093      // Should truncate to conservative max (50 chars)
2094      assert!(result.len() <= 50);
2095   }
2096}