Skip to main content

llm_git/
api.rs

1use std::{
2   path::Path,
3   sync::{LazyLock, OnceLock},
4   time::Duration,
5};
6
7use serde::{Deserialize, Serialize, de::DeserializeOwned};
8
9use crate::{
10   config::{CommitConfig, ResolvedApiMode},
11   error::{CommitGenError, Result},
12   templates,
13   tokens::TokenCounter,
14   types::{
15      CommitSummary, CommitType, ConventionalAnalysis, ConventionalCommit, coerce_optional_scope,
16   },
17};
18
19/// Whether API tracing is enabled (`LLM_GIT_TRACE=1`).
20static TRACE_ENABLED: LazyLock<bool> =
21   LazyLock::new(|| env_flag_value_enabled(std::env::var("LLM_GIT_TRACE").ok().as_deref()));
22
23/// Whether per-request LLM progress logging is enabled.
24///
25/// `LLM_GIT_PROGRESS=1` prints query/response/cache lines. `LLM_GIT_TRACE=1`
26/// implies this and also prints the lower-level trace line.
27static LLM_PROGRESS_ENABLED: LazyLock<bool> = LazyLock::new(|| {
28   env_flag_value_enabled(std::env::var("LLM_GIT_PROGRESS").ok().as_deref()) || trace_enabled()
29});
30
31fn env_flag_value_enabled(value: Option<&str>) -> bool {
32   let Some(value) = value else {
33      return false;
34   };
35
36   !matches!(value.trim().to_ascii_lowercase().as_str(), "" | "0" | "false" | "no" | "off")
37}
38
39/// Check if API request tracing is enabled via `LLM_GIT_TRACE` env var.
40fn trace_enabled() -> bool {
41   *TRACE_ENABLED
42}
43
44pub(crate) fn llm_progress_enabled() -> bool {
45   *LLM_PROGRESS_ENABLED
46}
47
48pub(crate) fn print_llm_progress(message: impl FnOnce() -> String) {
49   if llm_progress_enabled() {
50      crate::style::print_info(&message());
51   }
52}
53
54const fn api_mode_label(mode: ResolvedApiMode) -> &'static str {
55   match mode {
56      ResolvedApiMode::ChatCompletions => "chat completions",
57      ResolvedApiMode::AnthropicMessages => "Anthropic messages",
58   }
59}
60
61/// Send an HTTP request with timing instrumentation.
62///
63/// Measures TTFT (time to first byte / headers received) separately from total
64/// response time. Logs to stderr when `LLM_GIT_TRACE=1`.
65#[tracing::instrument(target = "lgit", name = "api.timed_send", skip_all, fields(operation = label, model))]
66pub async fn timed_send(
67   request_builder: reqwest::RequestBuilder,
68   label: &str,
69   model: &str,
70) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
71   let trace = trace_enabled();
72   let profile = crate::profile::enabled();
73   let start = std::time::Instant::now();
74
75   if profile {
76      tracing::info!(
77         target: crate::profile::TARGET,
78         event = "api_request_started",
79         operation = label,
80         model,
81      );
82   }
83
84   let response = match request_builder.send().await {
85      Ok(response) => response,
86      Err(error) => {
87         if profile {
88            let elapsed = start.elapsed();
89            tracing::warn!(
90               target: crate::profile::TARGET,
91               event = "api_request_failed",
92               operation = label,
93               model,
94               elapsed_ms = elapsed.as_secs_f64() * 1000.0,
95               elapsed_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
96               error = %error,
97            );
98         }
99         return Err(CommitGenError::HttpError(error));
100      },
101   };
102
103   let ttft = start.elapsed();
104   let status = response.status();
105   let content_length = response.content_length();
106
107   let body = match response.text().await {
108      Ok(body) => body,
109      Err(error) => {
110         if profile {
111            let elapsed = start.elapsed();
112            tracing::warn!(
113               target: crate::profile::TARGET,
114               event = "api_response_body_failed",
115               operation = label,
116               model,
117               status = status.as_u16(),
118               elapsed_ms = elapsed.as_secs_f64() * 1000.0,
119               elapsed_us = u64::try_from(elapsed.as_micros()).unwrap_or(u64::MAX),
120               error = %error,
121            );
122         }
123         return Err(CommitGenError::HttpError(error));
124      },
125   };
126   let total = start.elapsed();
127
128   if profile {
129      tracing::info!(
130         target: crate::profile::TARGET,
131         event = "api_request_finished",
132         operation = label,
133         model,
134         status = status.as_u16(),
135         success = status.is_success(),
136         ttft_ms = ttft.as_secs_f64() * 1000.0,
137         ttft_us = u64::try_from(ttft.as_micros()).unwrap_or(u64::MAX),
138         total_ms = total.as_secs_f64() * 1000.0,
139         total_us = u64::try_from(total.as_micros()).unwrap_or(u64::MAX),
140         body_bytes = body.len(),
141         content_length_known = content_length.is_some(),
142         content_length_bytes = content_length.unwrap_or(0),
143      );
144   }
145
146   if trace {
147      let size_info = content_length.map_or_else(
148         || format!("{}B", body.len()),
149         |cl| format!("{}B (content-length: {cl})", body.len()),
150      );
151      // Clear spinner line before printing (spinner writes \r to stdout)
152      if !crate::style::pipe_mode() {
153         print!("\r\x1b[K");
154         std::io::Write::flush(&mut std::io::stdout()).ok();
155      }
156      eprintln!(
157         "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
158          body={size_info}"
159      );
160   }
161
162   Ok((status, body))
163}
164
165// Prompts now loaded from config instead of compile-time constants
166
167/// Optional context information for commit analysis
168#[derive(Default)]
169pub struct AnalysisContext<'a> {
170   /// User-provided context
171   pub user_context:    Option<&'a str>,
172   /// Recent commits for style learning
173   pub recent_commits:  Option<&'a str>,
174   /// Common scopes for suggestions
175   pub common_scopes:   Option<&'a str>,
176   /// Project context (language, framework) for terminology
177   pub project_context: Option<&'a str>,
178   /// Debug output directory for saving raw I/O
179   pub debug_output:    Option<&'a Path>,
180   /// Prefix for debug output files to avoid collisions
181   pub debug_prefix:    Option<&'a str>,
182}
183
184/// Shared HTTP client, lazily initialized on first use.
185static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
186
187/// Get (or create) the shared HTTP client with timeouts from config.
188///
189/// The first call initializes the client with the given config's timeouts;
190/// subsequent calls reuse the same client regardless of config values.
191pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
192   CLIENT.get_or_init(|| {
193      reqwest::Client::builder()
194         .timeout(Duration::from_secs(config.request_timeout_secs))
195         .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
196         .build()
197         .expect("Failed to build HTTP client")
198   })
199}
200
201fn debug_filename(prefix: Option<&str>, name: &str) -> String {
202   match prefix {
203      Some(p) if !p.is_empty() => format!("{p}_{name}"),
204      _ => name.to_string(),
205   }
206}
207
208fn response_snippet(body: &str, limit: usize) -> String {
209   if body.is_empty() {
210      return "<empty response body>".to_string();
211   }
212   let mut snippet = body.trim().to_string();
213   if snippet.len() > limit {
214      snippet.truncate(limit);
215      snippet.push_str("...");
216   }
217   snippet
218}
219
220fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
221   let Some(dir) = debug_dir else {
222      return Ok(());
223   };
224
225   std::fs::create_dir_all(dir)?;
226   let path = dir.join(filename);
227   std::fs::write(&path, content)?;
228   Ok(())
229}
230
231fn anthropic_messages_url(base_url: &str) -> String {
232   let trimmed = base_url.trim_end_matches('/');
233   if trimmed.ends_with("/v1") {
234      format!("{trimmed}/messages")
235   } else {
236      format!("{trimmed}/v1/messages")
237   }
238}
239
240fn prompt_cache_control() -> PromptCacheControl {
241   PromptCacheControl { control_type: "ephemeral".to_string() }
242}
243
244fn anthropic_prompt_caching_enabled(config: &CommitConfig) -> bool {
245   config.api_base_url.to_lowercase().contains("anthropic.com")
246}
247
248fn append_anthropic_cache_beta_header(
249   request_builder: reqwest::RequestBuilder,
250   enable_cache: bool,
251) -> reqwest::RequestBuilder {
252   if enable_cache {
253      request_builder.header("anthropic-beta", "prompt-caching-2024-07-31")
254   } else {
255      request_builder
256   }
257}
258
259fn anthropic_text_content(text: String, cache: bool) -> AnthropicContent {
260   AnthropicContent {
261      content_type: "text".to_string(),
262      text,
263      cache_control: cache.then(prompt_cache_control),
264   }
265}
266
267fn anthropic_system_content(system_prompt: &str, cache: bool) -> Option<Vec<AnthropicContent>> {
268   if system_prompt.trim().is_empty() {
269      None
270   } else {
271      Some(vec![anthropic_text_content(system_prompt.to_string(), cache)])
272   }
273}
274
275fn supports_openai_prompt_cache_key(config: &CommitConfig) -> bool {
276   config
277      .api_base_url
278      .to_lowercase()
279      .contains("api.openai.com")
280}
281
282/// Generate a deterministic cache key for `OpenAI` prompt-prefix routing.
283pub fn openai_prompt_cache_key(
284   config: &CommitConfig,
285   model_name: &str,
286   prompt_family: &str,
287   prompt_variant: &str,
288   system_prompt: &str,
289) -> Option<String> {
290   if system_prompt.trim().is_empty() || !supports_openai_prompt_cache_key(config) {
291      return None;
292   }
293
294   Some(format!("llm-git:v1:{model_name}:{prompt_family}:{prompt_variant}"))
295}
296
297pub fn strict_json_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
298   serde_json::json!({
299      "type": "object",
300      "properties": properties,
301      "required": required,
302      "additionalProperties": false
303   })
304}
305
306pub(crate) fn extract_json_from_content(content: &str) -> String {
307   let trimmed = content.trim();
308
309   if trimmed.is_empty() {
310      return String::new();
311   }
312
313   if let Some(start) = trimmed.find("```json") {
314      let after_marker = &trimmed[start + 7..];
315      if let Some(end) = after_marker.find("```") {
316         return after_marker[..end].trim().to_string();
317      }
318   }
319
320   if let Some(start) = trimmed.find("```") {
321      let after_marker = &trimmed[start + 3..];
322      let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
323      let after_newline = &after_marker[content_start..];
324      if let Some(end) = after_newline.find("```") {
325         return after_newline[..end].trim().to_string();
326      }
327   }
328
329   if let Some(start) = trimmed.find('{')
330      && let Some(end) = trimmed.rfind('}')
331      && end >= start
332   {
333      return trimmed[start..=end].to_string();
334   }
335
336   trimmed.to_string()
337}
338
339#[derive(Debug, Clone, Copy, PartialEq, Eq)]
340pub enum OneShotSource {
341   ToolCall,
342   OutputJsonParse,
343   PlainTextContent,
344   Cache,
345}
346
347#[derive(Debug, Clone, Copy)]
348pub struct OneShotDebug<'a> {
349   pub dir:    Option<&'a Path>,
350   pub prefix: Option<&'a str>,
351   pub name:   &'a str,
352}
353
354#[derive(Debug, Clone, Copy)]
355pub struct OneShotSpec<'a> {
356   pub operation:        &'a str,
357   pub model:            &'a str,
358   pub prompt_family:    &'a str,
359   pub prompt_variant:   &'a str,
360   pub system_prompt:    &'a str,
361   pub user_prompt:      &'a str,
362   pub tool_name:        &'a str,
363   pub tool_description: &'a str,
364   pub schema:           &'a serde_json::Value,
365   pub progress_label:   Option<&'a str>,
366   pub debug:            Option<OneShotDebug<'a>>,
367   /// Look up / store the parsed response in the global LLM cache. Cache
368   /// entries are keyed on a hash of the spec fields plus prompts/schema.
369   pub cacheable:        bool,
370}
371
372#[derive(Debug)]
373pub struct OneShotResponse<T> {
374   pub output:       T,
375   pub source:       OneShotSource,
376   pub text_content: Option<String>,
377   pub stop_reason:  Option<String>,
378}
379
380fn oneshot_progress_label<'a>(spec: &OneShotSpec<'a>) -> &'a str {
381   spec.progress_label.unwrap_or(spec.operation)
382}
383
384const fn estimate_prompt_text_tokens(spec: &OneShotSpec<'_>) -> usize {
385   spec
386      .system_prompt
387      .len()
388      .saturating_add(spec.user_prompt.len())
389      .saturating_add(3)
390      / 4
391}
392
393const fn prompt_text_chars(spec: &OneShotSpec<'_>) -> usize {
394   spec
395      .system_prompt
396      .len()
397      .saturating_add(spec.user_prompt.len())
398}
399
400fn format_count(count: usize) -> String {
401   if count >= 10_000 {
402      format!("{:.1}k", count as f64 / 1000.0)
403   } else {
404      count.to_string()
405   }
406}
407
408fn format_elapsed(elapsed: Duration) -> String {
409   if elapsed.as_secs() > 0 {
410      format!("{:.1}s", elapsed.as_secs_f64())
411   } else {
412      format!("{}ms", elapsed.as_millis())
413   }
414}
415
416fn format_bytes(bytes: usize) -> String {
417   if bytes >= 1024 * 1024 {
418      format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
419   } else if bytes >= 1024 {
420      format!("{:.1}KB", bytes as f64 / 1024.0)
421   } else {
422      format!("{bytes}B")
423   }
424}
425
426fn format_llm_query_progress(spec: &OneShotSpec<'_>, mode: ResolvedApiMode) -> String {
427   format!(
428      "LLM query: {} \u{2192} {} ({}/{}, {}, {}, prompt ~{} tokens/{} chars)",
429      oneshot_progress_label(spec),
430      spec.model,
431      spec.prompt_family,
432      spec.prompt_variant,
433      api_mode_label(mode),
434      "tool call",
435      format_count(estimate_prompt_text_tokens(spec)),
436      format_count(prompt_text_chars(spec))
437   )
438}
439
440fn format_llm_response_progress(
441   spec: &OneShotSpec<'_>,
442   status: reqwest::StatusCode,
443   elapsed: Duration,
444   body_bytes: usize,
445) -> String {
446   format!(
447      "LLM response: {} \u{2190} {} (HTTP {}, {}, {})",
448      oneshot_progress_label(spec),
449      spec.model,
450      status.as_u16(),
451      format_elapsed(elapsed),
452      format_bytes(body_bytes)
453   )
454}
455
456fn format_llm_cache_progress(spec: &OneShotSpec<'_>) -> String {
457   format!(
458      "LLM cache hit: {} \u{2192} {} ({}/{})",
459      oneshot_progress_label(spec),
460      spec.model,
461      spec.prompt_family,
462      spec.prompt_variant
463   )
464}
465
466enum OneShotRequestOutcome {
467   Response { request_json: String, response_text: String },
468   Retry,
469}
470
471enum OneShotParseOutcome<T> {
472   Success(OneShotResponse<T>),
473   Retry,
474   Fatal(CommitGenError),
475}
476
477fn save_oneshot_debug<T: Serialize>(
478   debug: Option<OneShotDebug<'_>>,
479   phase: &str,
480   value: &T,
481) -> Result<()> {
482   let Some(debug) = debug else {
483      return Ok(());
484   };
485
486   let filename = debug_filename(debug.prefix, &format!("{}_{}.json", debug.name, phase));
487   let json = serde_json::to_string_pretty(value)?;
488   save_debug_output(debug.dir, &filename, &json)
489}
490
491fn save_oneshot_debug_text(debug: Option<OneShotDebug<'_>>, phase: &str, text: &str) -> Result<()> {
492   let Some(debug) = debug else {
493      return Ok(());
494   };
495
496   let filename = debug_filename(debug.prefix, &format!("{}_{}.json", debug.name, phase));
497   save_debug_output(debug.dir, &filename, text)
498}
499
500fn schema_properties(schema: &serde_json::Value) -> Result<serde_json::Value> {
501   schema
502      .get("properties")
503      .cloned()
504      .ok_or_else(|| CommitGenError::Other("Schema must include top-level properties".to_string()))
505}
506
507fn schema_required(schema: &serde_json::Value) -> Result<Vec<String>> {
508   schema
509      .get("required")
510      .and_then(|value| value.as_array())
511      .ok_or_else(|| {
512         CommitGenError::Other("Schema must include top-level required array".to_string())
513      })
514      .and_then(|values| {
515         values
516            .iter()
517            .map(|value| {
518               value.as_str().map(str::to_string).ok_or_else(|| {
519                  CommitGenError::Other("Schema required entries must be strings".to_string())
520               })
521            })
522            .collect()
523      })
524}
525
526fn build_openai_tool(
527   tool_name: &str,
528   tool_description: &str,
529   schema: &serde_json::Value,
530) -> Result<Tool> {
531   Ok(Tool {
532      tool_type: "function".to_string(),
533      function:  Function {
534         name:        tool_name.to_string(),
535         description: tool_description.to_string(),
536         parameters:  FunctionParameters {
537            param_type: "object".to_string(),
538            properties: schema_properties(schema)?,
539            required:   schema_required(schema)?,
540         },
541      },
542   })
543}
544
545fn build_anthropic_tool(
546   tool_name: &str,
547   tool_description: &str,
548   schema: &serde_json::Value,
549   prompt_caching: bool,
550) -> AnthropicTool {
551   let mut tool = AnthropicTool {
552      name:          tool_name.to_string(),
553      description:   tool_description.to_string(),
554      input_schema:  schema.clone(),
555      cache_control: None,
556   };
557
558   if prompt_caching {
559      tool.cache_control = Some(prompt_cache_control());
560   }
561
562   tool
563}
564
565fn is_context_length_error(body: &str) -> bool {
566   let lower = body.to_lowercase();
567   [
568      "context_length_exceeded",
569      "context window",
570      "maximum context length",
571      "exceeds the context",
572      "input exceeds",
573      "prompt is too long",
574      "too many tokens",
575   ]
576   .iter()
577   .any(|needle| lower.contains(needle))
578}
579
580async fn send_oneshot_request(
581   config: &CommitConfig,
582   spec: &OneShotSpec<'_>,
583   mode: ResolvedApiMode,
584   capture_request: bool,
585) -> Result<OneShotRequestOutcome> {
586   print_llm_progress(|| format_llm_query_progress(spec, mode));
587   match mode {
588      ResolvedApiMode::ChatCompletions => {
589         let prompt_cache_key = openai_prompt_cache_key(
590            config,
591            spec.model,
592            spec.prompt_family,
593            spec.prompt_variant,
594            spec.system_prompt,
595         );
596         let mut messages = Vec::new();
597         if !spec.system_prompt.trim().is_empty() {
598            messages.push(Message {
599               role:    "system".to_string(),
600               content: spec.system_prompt.to_string(),
601            });
602         }
603         messages
604            .push(Message { role: "user".to_string(), content: spec.user_prompt.to_string() });
605
606         // In markdown mode, omit the tool entirely so the model emits plain
607         // markdown text (parsed by the markdown fallback) instead of a tool call.
608         let (tools, tool_choice) = if config.markdown_output {
609            (vec![], None)
610         } else {
611            let tool = build_openai_tool(spec.tool_name, spec.tool_description, spec.schema)?;
612            (vec![tool], Some(serde_json::json!("required")))
613         };
614
615         let request = ApiRequest {
616            model: spec.model.to_string(),
617            tools,
618            tool_choice,
619            prompt_cache_key,
620            messages,
621         };
622
623         save_oneshot_debug(spec.debug, "request", &request)?;
624
625         let client = get_client(config);
626         let mut request_builder = client
627            .post(format!("{}/chat/completions", config.api_base_url))
628            .header("content-type", "application/json");
629
630         if let Some(api_key) = &config.api_key {
631            request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
632         }
633
634         let request_json = if capture_request {
635            serde_json::to_string(&request).unwrap_or_default()
636         } else {
637            String::new()
638         };
639         let request_start = std::time::Instant::now();
640         let (status, response_text) =
641            timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
642         print_llm_progress(|| {
643            format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
644         });
645         save_oneshot_debug_text(spec.debug, "response", &response_text)?;
646         if !status.is_success() && is_context_length_error(&response_text) {
647            return Err(CommitGenError::ApiContextLengthExceeded {
648               operation: spec.operation.to_string(),
649               model:     spec.model.to_string(),
650               status:    status.as_u16(),
651               body:      response_text,
652            });
653         }
654
655         if status.is_server_error() {
656            eprintln!(
657               "{}",
658               crate::style::error(&format!("Server error {status}: {response_text}"))
659            );
660            return Ok(OneShotRequestOutcome::Retry);
661         }
662
663         if !status.is_success() {
664            return Err(CommitGenError::ApiError {
665               status: status.as_u16(),
666               body:   response_text,
667            });
668         }
669
670         if response_text.trim().is_empty() {
671            crate::style::warn(&format!(
672               "Model returned empty response body for {}; retrying.",
673               spec.operation
674            ));
675            return Ok(OneShotRequestOutcome::Retry);
676         }
677
678         Ok(OneShotRequestOutcome::Response { request_json, response_text })
679      },
680      ResolvedApiMode::AnthropicMessages => {
681         let prompt_caching = anthropic_prompt_caching_enabled(config);
682         // In markdown mode, omit the tool so the model emits plain markdown text.
683         let (tools, tool_choice) = if config.markdown_output {
684            (vec![], None)
685         } else {
686            (
687               vec![build_anthropic_tool(
688                  spec.tool_name,
689                  spec.tool_description,
690                  spec.schema,
691                  prompt_caching,
692               )],
693               Some(AnthropicToolChoice {
694                  choice_type: "tool".to_string(),
695                  name:        spec.tool_name.to_string(),
696               }),
697            )
698         };
699         // The Anthropic Messages API requires max_tokens to be sent in the request.
700         // The user requested sending 16384 for Anthropic calls.
701         const ANTHROPIC_REQUIRED_MAX_TOKENS: u32 = 16384;
702         let request = AnthropicRequest {
703            model: spec.model.to_string(),
704            max_tokens: ANTHROPIC_REQUIRED_MAX_TOKENS,
705            system: anthropic_system_content(spec.system_prompt, prompt_caching),
706            tools,
707            tool_choice,
708            messages: vec![AnthropicMessage {
709               role:    "user".to_string(),
710               content: vec![anthropic_text_content(spec.user_prompt.to_string(), false)],
711            }],
712         };
713
714         save_oneshot_debug(spec.debug, "request", &request)?;
715
716         let client = get_client(config);
717         let mut request_builder = append_anthropic_cache_beta_header(
718            client
719               .post(anthropic_messages_url(&config.api_base_url))
720               .header("content-type", "application/json")
721               .header("anthropic-version", "2023-06-01"),
722            prompt_caching,
723         );
724
725         if let Some(api_key) = &config.api_key {
726            request_builder = request_builder.header("x-api-key", api_key);
727         }
728
729         let request_json = if capture_request {
730            serde_json::to_string(&request).unwrap_or_default()
731         } else {
732            String::new()
733         };
734         let request_start = std::time::Instant::now();
735         let (status, response_text) =
736            timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
737         print_llm_progress(|| {
738            format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
739         });
740         save_oneshot_debug_text(spec.debug, "response", &response_text)?;
741         if !status.is_success() && is_context_length_error(&response_text) {
742            return Err(CommitGenError::ApiContextLengthExceeded {
743               operation: spec.operation.to_string(),
744               model:     spec.model.to_string(),
745               status:    status.as_u16(),
746               body:      response_text,
747            });
748         }
749
750         if status.is_server_error() {
751            eprintln!(
752               "{}",
753               crate::style::error(&format!("Server error {status}: {response_text}"))
754            );
755            return Ok(OneShotRequestOutcome::Retry);
756         }
757
758         if !status.is_success() {
759            return Err(CommitGenError::ApiError {
760               status: status.as_u16(),
761               body:   response_text,
762            });
763         }
764
765         if response_text.trim().is_empty() {
766            crate::style::warn(&format!(
767               "Model returned empty response body for {}; retrying.",
768               spec.operation
769            ));
770            return Ok(OneShotRequestOutcome::Retry);
771         }
772
773         Ok(OneShotRequestOutcome::Response { request_json, response_text })
774      },
775   }
776}
777
778fn parse_json_output<T: DeserializeOwned>(json_text: &str, error_label: &str) -> Result<T> {
779   let candidate = extract_json_from_content(json_text);
780   serde_json::from_str(&candidate).map_err(|e| {
781      CommitGenError::Other(format!(
782         "Failed to parse {error_label}: {e}. Content: {}",
783         response_snippet(&candidate, 500)
784      ))
785   })
786}
787
788fn normalize_plain_text_content(content: &str) -> String {
789   let trimmed = content.trim();
790
791   if let Some(start) = trimmed.find("```") {
792      let after_marker = &trimmed[start + 3..];
793      let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
794      let after_newline = &after_marker[content_start..];
795      if let Some(end) = after_newline.find("```") {
796         return after_newline[..end].trim().to_string();
797      }
798   }
799
800   trimmed.to_string()
801}
802
803fn parse_plain_text_output<T: DeserializeOwned>(
804   tool_name: &str,
805   content: &str,
806   markdown_mode: bool,
807) -> Result<Option<T>> {
808   let trimmed = normalize_plain_text_content(content);
809   if trimmed.is_empty() {
810      return Ok(None);
811   }
812
813   let value = if markdown_mode {
814      // Try markdown parsing for all tool names in markdown mode
815      match tool_name {
816         "create_conventional_analysis" => {
817            crate::markdown_output::parse_conventional_analysis(&trimmed)
818         },
819         "create_commit_summary" => crate::markdown_output::parse_summary_output(&trimmed),
820         "create_changelog_entries" => crate::markdown_output::parse_changelog_response(&trimmed),
821         "create_compose_intent_plan" => crate::markdown_output::parse_compose_intent(&trimmed),
822         "bind_compose_hunks" => crate::markdown_output::parse_compose_binding(&trimmed),
823         "create_fast_commit" => crate::markdown_output::parse_fast_commit(&trimmed),
824         "create_file_observations" => crate::markdown_output::parse_batch_observations(&trimmed),
825         _ => return Ok(None),
826      }?
827   } else {
828      // Original JSON-wrapping behavior for backward compat
829      match tool_name {
830         "create_commit_summary" => serde_json::json!({ "summary": trimmed }),
831         _ => return Ok(None),
832      }
833   };
834
835   serde_json::from_value(value).map(Some).map_err(|e| {
836      CommitGenError::Other(format!(
837         "Failed to parse {tool_name} plain-text fallback: {e}. Content: {}",
838         response_snippet(&trimmed, 500)
839      ))
840   })
841}
842
843fn extract_anthropic_content(
844   response_text: &str,
845   tool_name: &str,
846) -> Result<(Option<serde_json::Value>, String, Option<String>)> {
847   let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
848      CommitGenError::Other(format!(
849         "Failed to parse Anthropic response JSON: {e}. Response body: {}",
850         response_snippet(response_text, 500)
851      ))
852   })?;
853
854   let stop_reason = value
855      .get("stop_reason")
856      .and_then(|v| v.as_str())
857      .map(str::to_string);
858
859   let mut tool_input: Option<serde_json::Value> = None;
860   let mut text_parts = Vec::new();
861
862   if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
863      for item in content {
864         let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
865         match item_type {
866            "tool_use" => {
867               let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
868               if name == tool_name
869                  && let Some(input) = item.get("input")
870               {
871                  tool_input = Some(input.clone());
872               }
873            },
874            "text" => {
875               if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
876                  text_parts.push(text.to_string());
877               }
878            },
879            _ => {},
880         }
881      }
882   }
883
884   Ok((tool_input, text_parts.join("\n"), stop_reason))
885}
886
887fn parse_oneshot_response<T: DeserializeOwned>(
888   mode: ResolvedApiMode,
889   tool_name: &str,
890   operation: &str,
891   response_text: &str,
892   markdown_mode: bool,
893) -> OneShotParseOutcome<T> {
894   match mode {
895      ResolvedApiMode::ChatCompletions => {
896         let api_response: ApiResponse = match serde_json::from_str(response_text) {
897            Ok(response) => response,
898            Err(e) => {
899               return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
900                  "Failed to parse {operation} response JSON: {e}. Response body: {}",
901                  response_snippet(response_text, 500)
902               )));
903            },
904         };
905
906         if api_response.choices.is_empty() {
907            return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
908               "API returned empty response for {operation}"
909            )));
910         }
911
912         let message = &api_response.choices[0].message;
913         if let Some(refusal) = &message.refusal {
914            return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
915               "Model refused {operation}: {refusal}"
916            )));
917         }
918
919         let mut last_error: Option<CommitGenError> = None;
920
921         if let Some(tool_call) = message.tool_calls.first()
922            && tool_call.function.name.ends_with(tool_name)
923         {
924            let args = tool_call.function.arguments.trim();
925            if args.is_empty() {
926               last_error = Some(CommitGenError::Other(format!(
927                  "Model returned empty function arguments for {operation}"
928               )));
929            } else {
930               match serde_json::from_str::<T>(args) {
931                  Ok(output) => {
932                     return OneShotParseOutcome::Success(OneShotResponse {
933                        output,
934                        source: OneShotSource::ToolCall,
935                        text_content: message.content.clone(),
936                        stop_reason: None,
937                     });
938                  },
939                  Err(e) => {
940                     last_error = Some(CommitGenError::Other(format!(
941                        "Failed to parse {operation} tool arguments: {e}. Args: {}",
942                        response_snippet(args, 500)
943                     )));
944                  },
945               }
946            }
947         }
948
949         if let Some(content) = &message.content {
950            if content.trim().is_empty() {
951               return OneShotParseOutcome::Retry;
952            }
953
954            match parse_json_output::<T>(content, &format!("{operation} content JSON")) {
955               Ok(output) => {
956                  return OneShotParseOutcome::Success(OneShotResponse {
957                     output,
958                     source: OneShotSource::OutputJsonParse,
959                     text_content: Some(content.clone()),
960                     stop_reason: None,
961                  });
962               },
963               Err(err) => match parse_plain_text_output::<T>(tool_name, content, markdown_mode) {
964                  Ok(Some(output)) => {
965                     return OneShotParseOutcome::Success(OneShotResponse {
966                        output,
967                        source: OneShotSource::PlainTextContent,
968                        text_content: Some(content.clone()),
969                        stop_reason: None,
970                     });
971                  },
972                  Ok(None) => last_error = Some(err),
973                  Err(fallback_err) => last_error = Some(fallback_err),
974               },
975            }
976         }
977
978         OneShotParseOutcome::Fatal(last_error.unwrap_or_else(|| {
979            CommitGenError::Other(format!("No {operation} found in API response"))
980         }))
981      },
982      ResolvedApiMode::AnthropicMessages => {
983         let (tool_input, text_content, stop_reason) =
984            match extract_anthropic_content(response_text, tool_name) {
985               Ok(content) => content,
986               Err(err) => return OneShotParseOutcome::Fatal(err),
987            };
988
989         let mut last_error: Option<CommitGenError> = None;
990
991         if let Some(input) = tool_input {
992            match serde_json::from_value::<T>(input) {
993               Ok(output) => {
994                  return OneShotParseOutcome::Success(OneShotResponse {
995                     output,
996                     source: OneShotSource::ToolCall,
997                     text_content: (!text_content.is_empty()).then_some(text_content),
998                     stop_reason,
999                  });
1000               },
1001               Err(e) => {
1002                  last_error = Some(CommitGenError::Other(format!(
1003                     "Failed to parse {operation} tool input: {e}. Response body: {}",
1004                     response_snippet(response_text, 500)
1005                  )));
1006               },
1007            }
1008         }
1009
1010         if text_content.trim().is_empty() {
1011            return OneShotParseOutcome::Retry;
1012         }
1013
1014         match parse_json_output::<T>(&text_content, &format!("{operation} content JSON")) {
1015            Ok(output) => OneShotParseOutcome::Success(OneShotResponse {
1016               output,
1017               source: OneShotSource::OutputJsonParse,
1018               text_content: Some(text_content),
1019               stop_reason,
1020            }),
1021            Err(err) => match parse_plain_text_output::<T>(tool_name, &text_content, markdown_mode)
1022            {
1023               Ok(Some(output)) => OneShotParseOutcome::Success(OneShotResponse {
1024                  output,
1025                  source: OneShotSource::PlainTextContent,
1026                  text_content: Some(text_content),
1027                  stop_reason,
1028               }),
1029               Ok(None) => OneShotParseOutcome::Fatal(last_error.unwrap_or(err)),
1030               Err(fallback_err) => OneShotParseOutcome::Fatal(last_error.unwrap_or(fallback_err)),
1031            },
1032         }
1033      },
1034   }
1035}
1036
1037#[tracing::instrument(target = "lgit", name = "api.run_oneshot", skip_all, fields(operation = spec.operation, model = spec.model, prompt_family = spec.prompt_family, prompt_variant = spec.prompt_variant))]
1038pub async fn run_oneshot<T>(
1039   config: &CommitConfig,
1040   spec: &OneShotSpec<'_>,
1041) -> Result<OneShotResponse<T>>
1042where
1043   T: DeserializeOwned + Serialize,
1044{
1045   let cache_entry = build_cache_entry(config, spec);
1046   if let Some((cache, key)) = cache_entry.as_ref()
1047      && let Some(stored) = cache.get(key)
1048      && let Ok(output) = serde_json::from_str::<T>(&stored)
1049   {
1050      print_llm_progress(|| format_llm_cache_progress(spec));
1051      return Ok(OneShotResponse {
1052         output,
1053         source: OneShotSource::Cache,
1054         text_content: None,
1055         stop_reason: None,
1056      });
1057   }
1058   // On parse failure (stale schema / wrong T) we silently fall through and
1059   // re-fetch — the next successful response will overwrite the stale entry.
1060
1061   let capture_request = cache_entry.is_some();
1062   let (response, request_json): (OneShotResponse<T>, Option<String>) =
1063      retry_api_call(config, async move || {
1064         let mode = config.resolved_api_mode(spec.model);
1065
1066         let (request_json, response_text) =
1067            match send_oneshot_request(config, spec, mode, capture_request).await? {
1068               OneShotRequestOutcome::Response { request_json, response_text } => {
1069                  (request_json, response_text)
1070               },
1071               OneShotRequestOutcome::Retry => return Ok((true, None)),
1072            };
1073
1074         match parse_oneshot_response::<T>(
1075            mode,
1076            spec.tool_name,
1077            spec.operation,
1078            &response_text,
1079            config.markdown_output,
1080         ) {
1081            OneShotParseOutcome::Success(output) => Ok((false, Some((output, Some(request_json))))),
1082            OneShotParseOutcome::Retry => Ok((true, None)),
1083            OneShotParseOutcome::Fatal(err) => Err(err),
1084         }
1085      })
1086      .await?;
1087
1088   if let Some((cache, key)) = cache_entry.as_ref()
1089      && let Ok(payload) = serde_json::to_string(&response.output)
1090   {
1091      cache.put(key, spec.model, spec.operation, request_json.as_deref().unwrap_or(""), &payload);
1092   }
1093
1094   Ok(response)
1095}
1096
1097fn build_cache_entry(
1098   config: &CommitConfig,
1099   spec: &OneShotSpec<'_>,
1100) -> Option<(std::sync::Arc<crate::llm_cache::LlmCache>, String)> {
1101   if !spec.cacheable {
1102      return None;
1103   }
1104   let cache = crate::llm_cache::global()?;
1105   let mode = config.resolved_api_mode(spec.model);
1106   let api_mode = match mode {
1107      ResolvedApiMode::ChatCompletions => "chat-completions",
1108      ResolvedApiMode::AnthropicMessages => "anthropic-messages",
1109   };
1110   let key = crate::llm_cache::compute_key(&crate::llm_cache::CacheMaterial {
1111      operation: spec.operation,
1112      model: spec.model,
1113      tool_name: spec.tool_name,
1114      tool_description: spec.tool_description,
1115      system_prompt: spec.system_prompt,
1116      user_prompt: spec.user_prompt,
1117      schema: spec.schema,
1118      api_mode,
1119   });
1120   Some((cache, key))
1121}
1122
1123#[derive(Debug, Serialize)]
1124struct Message {
1125   role:    String,
1126   content: String,
1127}
1128
1129#[derive(Debug, Serialize, Deserialize)]
1130struct FunctionParameters {
1131   #[serde(rename = "type")]
1132   param_type: String,
1133   properties: serde_json::Value,
1134   required:   Vec<String>,
1135}
1136
1137#[derive(Debug, Serialize, Deserialize)]
1138struct Function {
1139   name:        String,
1140   description: String,
1141   parameters:  FunctionParameters,
1142}
1143
1144#[derive(Debug, Serialize, Deserialize)]
1145struct Tool {
1146   #[serde(rename = "type")]
1147   tool_type: String,
1148   function:  Function,
1149}
1150
1151#[derive(Debug, Serialize)]
1152struct ApiRequest {
1153   model:            String,
1154   #[serde(skip_serializing_if = "Vec::is_empty")]
1155   tools:            Vec<Tool>,
1156   #[serde(skip_serializing_if = "Option::is_none")]
1157   tool_choice:      Option<serde_json::Value>,
1158   #[serde(skip_serializing_if = "Option::is_none")]
1159   prompt_cache_key: Option<String>,
1160   messages:         Vec<Message>,
1161}
1162
1163#[derive(Debug, Serialize)]
1164struct AnthropicRequest {
1165   model:       String,
1166   max_tokens:  u32,
1167   #[serde(skip_serializing_if = "Option::is_none")]
1168   system:      Option<Vec<AnthropicContent>>,
1169   #[serde(skip_serializing_if = "Vec::is_empty")]
1170   tools:       Vec<AnthropicTool>,
1171   #[serde(skip_serializing_if = "Option::is_none")]
1172   tool_choice: Option<AnthropicToolChoice>,
1173   messages:    Vec<AnthropicMessage>,
1174}
1175
1176#[derive(Debug, Clone, Serialize)]
1177struct PromptCacheControl {
1178   #[serde(rename = "type")]
1179   control_type: String,
1180}
1181
1182#[derive(Debug, Serialize)]
1183struct AnthropicTool {
1184   name:          String,
1185   description:   String,
1186   input_schema:  serde_json::Value,
1187   #[serde(skip_serializing_if = "Option::is_none")]
1188   cache_control: Option<PromptCacheControl>,
1189}
1190
1191#[derive(Debug, Serialize)]
1192struct AnthropicToolChoice {
1193   #[serde(rename = "type")]
1194   choice_type: String,
1195   name:        String,
1196}
1197
1198#[derive(Debug, Serialize)]
1199struct AnthropicMessage {
1200   role:    String,
1201   content: Vec<AnthropicContent>,
1202}
1203
1204#[derive(Debug, Clone, Serialize)]
1205struct AnthropicContent {
1206   #[serde(rename = "type")]
1207   content_type:  String,
1208   text:          String,
1209   #[serde(skip_serializing_if = "Option::is_none")]
1210   cache_control: Option<PromptCacheControl>,
1211}
1212
1213#[derive(Debug, Deserialize)]
1214struct ToolCall {
1215   function: FunctionCall,
1216}
1217
1218#[derive(Debug, Deserialize)]
1219struct FunctionCall {
1220   name:      String,
1221   arguments: String,
1222}
1223
1224#[derive(Debug, Deserialize)]
1225struct Choice {
1226   message: ResponseMessage,
1227}
1228
1229#[derive(Debug, Deserialize)]
1230struct ResponseMessage {
1231   #[serde(default)]
1232   tool_calls: Vec<ToolCall>,
1233   #[serde(default)]
1234   content:    Option<String>,
1235   #[serde(default)]
1236   refusal:    Option<String>,
1237}
1238
1239#[derive(Debug, Deserialize)]
1240struct ApiResponse {
1241   choices: Vec<Choice>,
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize)]
1245struct SummaryOutput {
1246   summary: String,
1247}
1248
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250struct FastCommitOutput {
1251   #[serde(rename = "type")]
1252   commit_type: String,
1253   #[serde(default)]
1254   scope:       Option<String>,
1255   summary:     String,
1256   #[serde(default)]
1257   details:     Vec<String>,
1258}
1259
1260const fn should_retry_error(error: &CommitGenError) -> bool {
1261   !matches!(error, CommitGenError::ApiContextLengthExceeded { .. })
1262}
1263/// Retry an API call with exponential backoff
1264#[tracing::instrument(target = "lgit", name = "api.retry", skip_all, fields(max_retries = config.max_retries))]
1265pub async fn retry_api_call<T>(
1266   config: &CommitConfig,
1267   mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
1268) -> Result<T> {
1269   let mut attempt = 0;
1270
1271   loop {
1272      attempt += 1;
1273      if crate::profile::enabled() {
1274         tracing::info!(
1275            target: crate::profile::TARGET,
1276            event = "api_retry_attempt_started",
1277            attempt,
1278            max_retries = config.max_retries,
1279         );
1280      }
1281
1282      match f().await {
1283         Ok((false, Some(result))) => return Ok(result),
1284         Ok((false, None)) => {
1285            return Err(CommitGenError::Other("API call failed without result".to_string()));
1286         },
1287         Ok((true, _)) if attempt < config.max_retries => {
1288            let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1289            if crate::profile::enabled() {
1290               tracing::warn!(
1291                  target: crate::profile::TARGET,
1292                  event = "api_retry_scheduled",
1293                  attempt,
1294                  max_retries = config.max_retries,
1295                  backoff_ms,
1296                  reason = "retryable_response",
1297               );
1298            }
1299            eprintln!(
1300               "{}",
1301               crate::style::warning(&format!(
1302                  "Retry {}/{} after {}ms...",
1303                  attempt, config.max_retries, backoff_ms
1304               ))
1305            );
1306            tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1307         },
1308         Ok((true, _last_err)) => {
1309            return Err(CommitGenError::ApiRetryExhausted {
1310               retries: config.max_retries,
1311               source:  Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
1312            });
1313         },
1314         Err(e) => {
1315            if !should_retry_error(&e) {
1316               return Err(e);
1317            }
1318
1319            if attempt < config.max_retries {
1320               let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1321               if crate::profile::enabled() {
1322                  tracing::warn!(
1323                     target: crate::profile::TARGET,
1324                     event = "api_retry_scheduled",
1325                     attempt,
1326                     max_retries = config.max_retries,
1327                     backoff_ms,
1328                     reason = "error",
1329                     error = %e,
1330                  );
1331               }
1332               eprintln!(
1333                  "{}",
1334                  crate::style::warning(&format!(
1335                     "Error: {} - Retry {}/{} after {}ms...",
1336                     e, attempt, config.max_retries, backoff_ms
1337                  ))
1338               );
1339               tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1340               continue;
1341            }
1342            return Err(e);
1343         },
1344      }
1345   }
1346}
1347
1348/// Format commit types from config into a rich description for the prompt
1349/// Order is preserved from config (first = highest priority)
1350pub fn format_types_description(config: &CommitConfig) -> String {
1351   use std::fmt::Write;
1352   let mut out = String::from("Check types in order (first match wins):\n\n");
1353
1354   for (name, tc) in &config.types {
1355      let _ = writeln!(out, "**{name}**: {}", tc.description);
1356      if !tc.diff_indicators.is_empty() {
1357         let _ = writeln!(out, "  Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
1358      }
1359      if !tc.file_patterns.is_empty() {
1360         let _ = writeln!(out, "  File patterns: {}", tc.file_patterns.join(", "));
1361      }
1362      for ex in &tc.examples {
1363         let _ = writeln!(out, "  - {ex}");
1364      }
1365      if !tc.hint.is_empty() {
1366         let _ = writeln!(out, "  Note: {}", tc.hint);
1367      }
1368      out.push('\n');
1369   }
1370
1371   if !config.classifier_hint.is_empty() {
1372      let _ = writeln!(out, "\n{}", config.classifier_hint);
1373   }
1374
1375   out
1376}
1377
1378/// Generate conventional commit analysis using OpenAI-compatible API
1379#[tracing::instrument(target = "lgit", name = "api.generate_conventional_analysis", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1380pub async fn generate_conventional_analysis<'a>(
1381   stat: &'a str,
1382   diff: &'a str,
1383   model_name: &'a str,
1384   scope_candidates_str: &'a str,
1385   ctx: &AnalysisContext<'a>,
1386   config: &'a CommitConfig,
1387) -> Result<ConventionalAnalysis> {
1388   let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1389
1390   let analysis_schema = strict_json_schema(
1391      serde_json::json!({
1392         "type": {
1393            "type": "string",
1394            "enum": type_enum,
1395            "description": "Commit type based on change classification"
1396         },
1397         "scope": {
1398            "type": "string",
1399            "description": "Optional scope (module/component). Omit if unclear or multi-component."
1400         },
1401         "summary": {
1402            "type": "string",
1403            "description": format!(
1404               "Umbrella commit summary without type/scope prefix or trailing period; target {} chars, hard limit {}.",
1405               config.summary_guideline,
1406               config.summary_hard_limit
1407            ),
1408            "maxLength": config.summary_hard_limit
1409         },
1410         "details": {
1411            "type": "array",
1412            "description": "Array of 0-6 detail items with changelog metadata.",
1413            "items": {
1414               "type": "object",
1415               "properties": {
1416                  "text": {
1417                     "type": "string",
1418                     "description": "Detail about change, starting with past-tense verb, ending with period"
1419                  },
1420                  "changelog_category": {
1421                     "type": "string",
1422                     "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
1423                     "description": "Changelog category if user-visible. Omit for internal changes."
1424                  },
1425                  "user_visible": {
1426                     "type": "boolean",
1427                     "description": "True if this change affects users/API and should appear in changelog"
1428                  }
1429               },
1430               "required": ["text", "user_visible"]
1431            }
1432         },
1433         "issue_refs": {
1434            "type": "array",
1435            "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
1436            "items": { "type": "string" }
1437         }
1438      }),
1439      &["type", "summary", "details", "issue_refs"],
1440   );
1441
1442   let prompt_variant = if config.markdown_output {
1443      "markdown"
1444   } else {
1445      &config.analysis_prompt_variant
1446   };
1447
1448   let types_desc = format_types_description(config);
1449   let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
1450      variant: prompt_variant,
1451      stat,
1452      diff,
1453      scope_candidates: scope_candidates_str,
1454      recent_commits: ctx.recent_commits,
1455      common_scopes: ctx.common_scopes,
1456      types_description: Some(&types_desc),
1457      project_context: ctx.project_context,
1458   })?;
1459
1460   let user_prompt = if let Some(user_ctx) = ctx.user_context {
1461      format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
1462   } else {
1463      parts.user
1464   };
1465
1466   let response = run_oneshot::<ConventionalAnalysis>(config, &OneShotSpec {
1467      operation: "analysis",
1468      model: model_name,
1469      prompt_family: "analysis",
1470      prompt_variant,
1471      system_prompt: &parts.system,
1472      user_prompt: &user_prompt,
1473      tool_name: "create_conventional_analysis",
1474      tool_description: "Analyze changes and classify as conventional commit with type, scope, \
1475                         summary, details, and metadata",
1476      schema: &analysis_schema,
1477      progress_label: Some("analysis"),
1478      debug: Some(OneShotDebug {
1479         dir:    ctx.debug_output,
1480         prefix: ctx.debug_prefix,
1481         name:   "analysis",
1482      }),
1483      cacheable: true,
1484   })
1485   .await?;
1486
1487   Ok(response.output)
1488}
1489
1490/// Strip conventional commit type prefix if LLM included it in summary.
1491///
1492/// Some models return the full format `feat(scope): summary` instead of just
1493/// `summary`. This function removes the prefix to normalize the response.
1494///
1495/// Tries the exact `type(scope): ` prefix first, then `type: ` (no scope),
1496/// then a generic `type(any-scope): ` pattern. All comparisons are
1497/// case-insensitive on the type token so `Fix(tui):` / `Feat: ` are stripped
1498/// too.
1499pub fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
1500   let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
1501   let prefix = format!("{commit_type}{scope_part}: ");
1502
1503   if let Some(stripped) = summary.strip_prefix(&prefix) {
1504      return stripped.to_string();
1505   }
1506
1507   // Try without scope in case model omitted it
1508   let prefix_no_scope = format!("{commit_type}: ");
1509   if let Some(stripped) = summary.strip_prefix(&prefix_no_scope) {
1510      return stripped.to_string();
1511   }
1512
1513   // Case-insensitive fallbacks: models sometimes emit `Fix(tui):` or
1514   // `Feat: `. Check if the summary starts with the type token (ignoring
1515   // case) followed by `(` or `:`.
1516   let summary_lower = summary.to_ascii_lowercase();
1517   let commit_lower = commit_type.to_ascii_lowercase();
1518
1519   // Generic `type(any-scope): ` — model emits a different scope than parsed.
1520   let generic_prefix = format!("{commit_lower}(");
1521   if let Some(after_type) = summary_lower.strip_prefix(&generic_prefix) {
1522      if let Some(close) = after_type.find("): ") {
1523         return summary[commit_type.len() + 1 + close + 3..].to_string();
1524      }
1525      if let Some(close) = after_type.find("):") {
1526         return summary[commit_type.len() + 1 + close + 2..]
1527            .trim_start()
1528            .to_string();
1529      }
1530   }
1531
1532   // Case-insensitive `type: ` (no scope)
1533   let prefix_no_scope_lower = format!("{commit_lower}: ");
1534   if summary_lower.starts_with(&prefix_no_scope_lower) {
1535      return summary[commit_type.len() + 2..].to_string();
1536   }
1537
1538   summary.to_string()
1539}
1540
1541/// Build a commit summary from the holistic analysis response when present.
1542///
1543/// Returns `None` for map-reduce or legacy responses that do not include the
1544/// optional `summary` field.
1545pub fn summary_from_holistic_analysis(
1546   analysis: &ConventionalAnalysis,
1547   config: &CommitConfig,
1548) -> Result<Option<CommitSummary>> {
1549   let Some(raw_summary) = analysis
1550      .summary
1551      .as_deref()
1552      .map(str::trim)
1553      .filter(|summary| !summary.is_empty())
1554   else {
1555      return Ok(None);
1556   };
1557
1558   let cleaned = strip_type_prefix(
1559      raw_summary,
1560      analysis.commit_type.as_str(),
1561      analysis.scope.as_ref().map(|scope| scope.as_str()),
1562   );
1563
1564   CommitSummary::new(cleaned, config.summary_hard_limit).map(Some)
1565}
1566
1567/// Validate summary against requirements
1568fn validate_summary_quality(
1569   summary: &str,
1570   commit_type: &str,
1571   stat: &str,
1572) -> std::result::Result<(), String> {
1573   use crate::validation::is_past_tense_first_word;
1574
1575   let first_word = summary
1576      .split_whitespace()
1577      .next()
1578      .ok_or_else(|| "summary is empty".to_string())?;
1579
1580   let first_word_lower = first_word.to_lowercase();
1581
1582   // Check past-tense verb (tolerates trailing non-alpha suffixes like
1583   // `bound-check`, and all-caps acronyms are rejected).
1584   if !is_past_tense_first_word(first_word) {
1585      return Err(format!(
1586         "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
1587      ));
1588   }
1589
1590   // Check type repetition
1591   if first_word_lower == commit_type {
1592      return Err(format!("repeats commit type '{commit_type}' in summary"));
1593   }
1594
1595   // Type-file mismatch heuristic
1596   let file_exts: Vec<&str> = stat
1597      .lines()
1598      .filter_map(|line| {
1599         let path = line.split('|').next()?.trim();
1600         std::path::Path::new(path).extension()?.to_str()
1601      })
1602      .collect();
1603
1604   if !file_exts.is_empty() {
1605      let total = file_exts.len();
1606      let md_count = file_exts.iter().filter(|&&e| e == "md").count();
1607
1608      // If >80% markdown but not docs type, suggest docs
1609      if md_count * 100 / total > 80 && commit_type != "docs" {
1610         crate::style::warn(&format!(
1611            "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
1612            md_count * 100 / total,
1613            commit_type
1614         ));
1615      }
1616
1617      // If no code files and type=feat/fix, warn
1618      let code_exts = [
1619         // Systems programming
1620         "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
1621         // JVM languages
1622         "java", "kt", "kts", "scala", "groovy", "clj", "cljs", // .NET languages
1623         "cs", "fs", "vb", // Web/scripting
1624         "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", // Python ecosystem
1625         "py", "pyx", "pxd", "pyi", // Ruby
1626         "rb", "rake", "gemspec", // PHP
1627         "php",     // Go
1628         "go",      // Swift/Objective-C
1629         "swift", "m", "mm",  // Lua
1630         "lua", // Shell
1631         "sh", "bash", "zsh", "fish", // Perl
1632         "pl", "pm", // Haskell/ML family
1633         "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
1634         // Lisp family
1635         "lisp", "cl", "el", "scm", "rkt", // Julia
1636         "jl",  // R
1637         "r", "R",    // Dart/Flutter
1638         "dart", // Crystal
1639         "cr",   // D
1640         "d",    // Fortran
1641         "f", "f90", "f95", "f03", "f08", // Ada
1642         "ada", "adb", "ads", // Cobol
1643         "cob", "cbl", // Assembly
1644         "asm", "s", "S", // SQL (stored procs)
1645         "sql", "plsql", // Prolog
1646         "pl", "pro", // OCaml/ReasonML
1647         "re", "rei", // Nix
1648         "nix", // Terraform/HCL
1649         "tf", "hcl",  // Solidity
1650         "sol",  // Move
1651         "move", // Cairo
1652         "cairo",
1653      ];
1654      let code_count = file_exts
1655         .iter()
1656         .filter(|&&e| code_exts.contains(&e))
1657         .count();
1658      if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
1659         crate::style::warn(&format!(
1660            "Type mismatch: no code files changed but type is '{commit_type}'"
1661         ));
1662      }
1663   }
1664
1665   Ok(())
1666}
1667
1668/// Create commit summary using a smaller model focused on detail retention
1669#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
1670#[tracing::instrument(target = "lgit", name = "api.generate_summary_from_analysis", skip_all, fields(commit_type, scope = ?scope, detail_count = details.len(), model = %config.summary_model))]
1671pub async fn generate_summary_from_analysis<'a>(
1672   stat: &'a str,
1673   commit_type: &'a str,
1674   scope: Option<&'a str>,
1675   details: &'a [String],
1676   user_context: Option<&'a str>,
1677   config: &'a CommitConfig,
1678   debug_dir: Option<&'a Path>,
1679   debug_prefix: Option<&'a str>,
1680) -> Result<CommitSummary> {
1681   let mut validation_attempt = 0;
1682   let max_validation_retries = 1;
1683   let mut last_failure_reason: Option<String> = None;
1684
1685   loop {
1686      let additional_constraint = if let Some(reason) = &last_failure_reason {
1687         format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1688      } else {
1689         String::new()
1690      };
1691
1692      let bullet_points = details.join("\n");
1693      let details_str = if bullet_points.is_empty() {
1694         "None (no supporting detail points were generated)."
1695      } else {
1696         bullet_points.as_str()
1697      };
1698
1699      let scope_str = scope.unwrap_or("");
1700      let prefix_len =
1701         commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 };
1702      let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
1703
1704      let summary_variant = if config.markdown_output {
1705         "markdown"
1706      } else {
1707         &config.summary_prompt_variant
1708      };
1709
1710      let parts = templates::render_summary_prompt(
1711         summary_variant,
1712         commit_type,
1713         scope_str,
1714         &max_summary_len.to_string(),
1715         details_str,
1716         stat.trim(),
1717         user_context,
1718      )?;
1719
1720      let user_prompt = format!("{}{additional_constraint}", parts.user);
1721      let summary_schema = strict_json_schema(
1722         serde_json::json!({
1723            "summary": {
1724               "type": "string",
1725               "description": format!(
1726                  "Single line summary, target {} chars (hard limit {}), past tense verb first.",
1727                  config.summary_guideline,
1728                  config.summary_hard_limit
1729               ),
1730               "maxLength": config.summary_hard_limit
1731            }
1732         }),
1733         &["summary"],
1734      );
1735
1736      let response = run_oneshot::<SummaryOutput>(config, &OneShotSpec {
1737         operation:        "summary",
1738         model:            &config.summary_model,
1739         prompt_family:    "summary",
1740         prompt_variant:   summary_variant,
1741         system_prompt:    &parts.system,
1742         user_prompt:      &user_prompt,
1743         tool_name:        "create_commit_summary",
1744         tool_description: "Compose a git commit summary line from detail statements",
1745         schema:           &summary_schema,
1746         progress_label:   Some("summary"),
1747         debug:            Some(OneShotDebug {
1748            dir:    debug_dir,
1749            prefix: debug_prefix,
1750            name:   "summary",
1751         }),
1752         cacheable:        true,
1753      })
1754      .await;
1755
1756      match response {
1757         Ok(response) => {
1758            let cleaned = strip_type_prefix(&response.output.summary, commit_type, scope);
1759            // Normalize present->past tense before validation so Gemini
1760            // summaries like "harden ..." get converted to "hardened ...".
1761            let mut normalized = cleaned;
1762            crate::normalization::normalize_summary_verb(&mut normalized, commit_type);
1763            let summary = CommitSummary::new(&normalized, config.summary_hard_limit)?;
1764
1765            match validate_summary_quality(summary.as_str(), commit_type, stat) {
1766               Ok(()) => return Ok(summary),
1767               Err(reason) if validation_attempt < max_validation_retries => {
1768                  crate::style::warn(&format!(
1769                     "Validation failed (attempt {}/{}): {}",
1770                     validation_attempt + 1,
1771                     max_validation_retries + 1,
1772                     reason
1773                  ));
1774                  last_failure_reason = Some(reason);
1775                  validation_attempt += 1;
1776               },
1777               Err(reason) => {
1778                  crate::style::warn(&format!(
1779                     "Validation failed after {} retries: {}. Using fallback.",
1780                     max_validation_retries + 1,
1781                     reason
1782                  ));
1783                  return Ok(fallback_from_details_or_summary(
1784                     details,
1785                     summary.as_str(),
1786                     commit_type,
1787                     config,
1788                  ));
1789               },
1790            }
1791         },
1792         Err(e) => return Err(e),
1793      }
1794   }
1795}
1796
1797/// Fallback when validation fails: use first detail, strip type word if present
1798fn fallback_from_details_or_summary(
1799   details: &[String],
1800   invalid_summary: &str,
1801   commit_type: &str,
1802   config: &CommitConfig,
1803) -> CommitSummary {
1804   let candidate = if let Some(first_detail) = details.first() {
1805      // Use first detail line, strip type word
1806      let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1807
1808      // Remove type word if present at start
1809      let type_word_variants =
1810         [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1811      for variant in &type_word_variants {
1812         if cleaned
1813            .to_lowercase()
1814            .starts_with(&format!("{} ", variant.to_lowercase()))
1815         {
1816            cleaned = cleaned[variant.len()..].trim().to_string();
1817            break;
1818         }
1819      }
1820
1821      cleaned
1822   } else {
1823      // No details, try to fix invalid summary
1824      let mut cleaned = invalid_summary
1825         .split_whitespace()
1826         .skip(1) // Remove first word (invalid verb)
1827         .collect::<Vec<_>>()
1828         .join(" ");
1829
1830      if cleaned.is_empty() {
1831         cleaned = fallback_summary("", details, commit_type, config)
1832            .as_str()
1833            .to_string();
1834      }
1835
1836      cleaned
1837   };
1838
1839   // Ensure valid past-tense verb prefix
1840   let with_verb = if candidate
1841      .split_whitespace()
1842      .next()
1843      .is_some_and(crate::validation::is_past_tense_first_word)
1844   {
1845      candidate
1846   } else {
1847      let verb = match commit_type {
1848         "feat" => "added",
1849         "fix" => "fixed",
1850         "refactor" => "restructured",
1851         "docs" => "documented",
1852         "test" => "tested",
1853         "perf" => "optimized",
1854         "build" | "ci" | "chore" => "updated",
1855         "style" => "formatted",
1856         "revert" => "reverted",
1857         _ => "changed",
1858      };
1859      format!("{verb} {candidate}")
1860   };
1861
1862   CommitSummary::new(with_verb, config.summary_hard_limit)
1863      .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1864}
1865
1866/// Provide a deterministic fallback summary if model generation fails
1867pub fn fallback_summary(
1868   stat: &str,
1869   details: &[String],
1870   commit_type: &str,
1871   config: &CommitConfig,
1872) -> CommitSummary {
1873   let mut candidate = if let Some(first) = details.first() {
1874      first.trim().trim_end_matches('.').to_string()
1875   } else {
1876      let primary_line = stat
1877         .lines()
1878         .map(str::trim)
1879         .find(|line| !line.is_empty())
1880         .unwrap_or("files");
1881
1882      let subject = primary_line
1883         .split('|')
1884         .next()
1885         .map(str::trim)
1886         .filter(|s| !s.is_empty())
1887         .unwrap_or("files");
1888
1889      if subject.eq_ignore_ascii_case("files") {
1890         "Updated files".to_string()
1891      } else {
1892         format!("Updated {subject}")
1893      }
1894   };
1895
1896   candidate = candidate
1897      .replace(['\n', '\r'], " ")
1898      .split_whitespace()
1899      .collect::<Vec<_>>()
1900      .join(" ")
1901      .trim()
1902      .trim_end_matches('.')
1903      .trim_end_matches(';')
1904      .trim_end_matches(':')
1905      .to_string();
1906
1907   if candidate.is_empty() {
1908      candidate = "Updated files".to_string();
1909   }
1910
1911   // Truncate to conservative length (50 chars) since we don't know the scope yet
1912   // post_process_commit_message will truncate further if needed
1913   const CONSERVATIVE_MAX: usize = 50;
1914   while candidate.len() > CONSERVATIVE_MAX {
1915      if let Some(pos) = candidate.rfind(' ') {
1916         candidate.truncate(pos);
1917         candidate = candidate.trim_end_matches(',').trim().to_string();
1918      } else {
1919         candidate.truncate(CONSERVATIVE_MAX);
1920         break;
1921      }
1922   }
1923
1924   // Ensure no trailing period (conventional commits style)
1925   candidate = candidate.trim_end_matches('.').to_string();
1926
1927   // If the candidate ended up identical to the commit type, replace with a safer
1928   // default
1929   if candidate
1930      .split_whitespace()
1931      .next()
1932      .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1933   {
1934      candidate = match commit_type {
1935         "refactor" => "restructured change".to_string(),
1936         "feat" => "added functionality".to_string(),
1937         "fix" => "fixed issue".to_string(),
1938         "docs" => "documented updates".to_string(),
1939         "test" => "tested changes".to_string(),
1940         "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1941         "perf" => "optimized performance".to_string(),
1942         "revert" => "reverted previous commit".to_string(),
1943         _ => "updated files".to_string(),
1944      };
1945   }
1946
1947   // Unwrap is safe: fallback_summary guarantees non-empty string ≤50 chars (<
1948   // config limit)
1949   CommitSummary::new(candidate, config.summary_hard_limit)
1950      .expect("fallback summary should always be valid")
1951}
1952
1953/// Generate conventional commit analysis, using map-reduce for large diffs
1954///
1955/// This is the main entry point for analysis. It automatically routes to
1956/// map-reduce when the diff exceeds the configured token threshold.
1957#[tracing::instrument(target = "lgit", name = "api.generate_analysis_with_map_reduce", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1958pub async fn generate_analysis_with_map_reduce<'a>(
1959   stat: &'a str,
1960   diff: &'a str,
1961   model_name: &'a str,
1962   scope_candidates_str: &'a str,
1963   ctx: &AnalysisContext<'a>,
1964   config: &'a CommitConfig,
1965   counter: &TokenCounter,
1966) -> Result<ConventionalAnalysis> {
1967   use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1968
1969   if should_use_map_reduce(diff, config, counter) {
1970      crate::style::print_info(&format!(
1971         "Large diff detected ({} tokens), using map-reduce...",
1972         counter.count_sync(diff)
1973      ));
1974      run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1975   } else {
1976      generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1977         .await
1978   }
1979}
1980
1981/// Generate a complete commit in a single API call (fast mode).
1982///
1983/// Returns a `ConventionalCommit` directly — no separate summary phase.
1984#[tracing::instrument(target = "lgit", name = "api.generate_fast_commit", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1985pub async fn generate_fast_commit(
1986   stat: &str,
1987   diff: &str,
1988   model_name: &str,
1989   scope_candidates_str: &str,
1990   user_context: Option<&str>,
1991   config: &CommitConfig,
1992   debug_dir: Option<&Path>,
1993) -> Result<ConventionalCommit> {
1994   let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1995   let types_desc = format_types_description(config);
1996
1997   let fast_variant = if config.markdown_output {
1998      "markdown"
1999   } else {
2000      "default"
2001   };
2002   let parts = templates::render_fast_prompt(&templates::FastPromptParams {
2003      variant: fast_variant,
2004      stat,
2005      diff,
2006      scope_candidates: scope_candidates_str,
2007      user_context,
2008      types_description: Some(&types_desc),
2009   })?;
2010
2011   let fast_schema = strict_json_schema(
2012      serde_json::json!({
2013         "type": {
2014            "type": "string",
2015            "enum": type_enum,
2016            "description": "Conventional commit type"
2017         },
2018         "scope": {
2019            "type": "string",
2020            "description": "Optional scope. Omit if unclear or cross-cutting."
2021         },
2022         "summary": {
2023            "type": "string",
2024            "description": "≤72 char past-tense summary, no type prefix, no trailing period"
2025         },
2026         "details": {
2027            "type": "array",
2028            "items": { "type": "string" },
2029            "description": "0-3 past-tense detail sentences ending with period"
2030         }
2031      }),
2032      &["type", "summary", "details"],
2033   );
2034
2035   let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
2036      operation:        "fast",
2037      model:            model_name,
2038      prompt_family:    "fast",
2039      prompt_variant:   fast_variant,
2040      system_prompt:    &parts.system,
2041      user_prompt:      &parts.user,
2042      tool_name:        "create_fast_commit",
2043      tool_description: "Generate a conventional commit from the given diff",
2044      schema:           &fast_schema,
2045      progress_label:   Some("fast commit"),
2046      debug:            Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
2047      cacheable:        true,
2048   })
2049   .await?;
2050
2051   build_fast_commit(response.output, config)
2052}
2053
2054/// Convert a `FastCommitOutput` into a validated `ConventionalCommit`.
2055fn build_fast_commit(
2056   output: FastCommitOutput,
2057   config: &CommitConfig,
2058) -> Result<ConventionalCommit> {
2059   let commit_type = CommitType::new(&output.commit_type)?;
2060   let scope = coerce_optional_scope(output.scope.as_deref());
2061   let cleaned_summary =
2062      strip_type_prefix(&output.summary, commit_type.as_str(), scope.as_ref().map(|s| s.as_str()));
2063   let summary = CommitSummary::new(&cleaned_summary, config.summary_hard_limit)?;
2064   Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2065}
2066#[cfg(test)]
2067mod tests {
2068   use super::*;
2069   use crate::config::CommitConfig;
2070
2071   #[test]
2072   fn test_strip_type_prefix_exact_scope() {
2073      assert_eq!(strip_type_prefix("fix(api): fixed bug", "fix", Some("api")), "fixed bug");
2074   }
2075
2076   #[test]
2077   fn test_strip_type_prefix_no_scope() {
2078      assert_eq!(strip_type_prefix("fix: fixed bug", "fix", None), "fixed bug");
2079   }
2080
2081   #[test]
2082   fn test_strip_type_prefix_different_scope() {
2083      // Model emits scope we didn't parse (e.g. scope is None but model wrote
2084      // fix(tui):)
2085      assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", None), "fixed bug");
2086      // Model emits different scope than parsed
2087      assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2088   }
2089
2090   #[test]
2091   fn test_strip_type_prefix_no_prefix() {
2092      // No prefix present — should return unchanged
2093      assert_eq!(strip_type_prefix("fixed bug", "fix", None), "fixed bug");
2094   }
2095
2096   #[test]
2097   fn test_strip_type_prefix_wrong_type_not_stripped() {
2098      // Should not strip a prefix with a different type
2099      assert_eq!(
2100         strip_type_prefix("feat(api): added feature", "fix", None),
2101         "feat(api): added feature"
2102      );
2103   }
2104
2105   #[test]
2106   fn test_strip_type_prefix_capitalized_type_with_scope() {
2107      // Model emits `Fix(tui):` with capital F
2108      assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", None), "fixed bug");
2109      assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2110   }
2111
2112   #[test]
2113   fn test_strip_type_prefix_capitalized_type_no_scope() {
2114      // Model emits `Feat: ` with capital F
2115      assert_eq!(strip_type_prefix("Feat: added feature", "feat", None), "added feature");
2116   }
2117
2118   #[test]
2119   fn test_strip_type_prefix_uppercase_type() {
2120      // Model emits `FIX(api):` all caps
2121      assert_eq!(strip_type_prefix("FIX(api): fixed bug", "fix", Some("api")), "fixed bug");
2122   }
2123
2124   #[test]
2125   fn test_strict_json_schema_disallows_extra_properties() {
2126      let schema =
2127         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2128      assert_eq!(schema["type"], "object");
2129      assert_eq!(schema["required"], serde_json::json!(["summary"]));
2130      assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2131   }
2132
2133   #[test]
2134   fn test_env_flag_value_enabled_uses_boolean_semantics() {
2135      assert!(!env_flag_value_enabled(None));
2136      assert!(!env_flag_value_enabled(Some("")));
2137      assert!(!env_flag_value_enabled(Some("0")));
2138      assert!(!env_flag_value_enabled(Some("false")));
2139      assert!(!env_flag_value_enabled(Some("NO")));
2140      assert!(!env_flag_value_enabled(Some("off")));
2141      assert!(env_flag_value_enabled(Some("1")));
2142      assert!(env_flag_value_enabled(Some("true")));
2143      assert!(env_flag_value_enabled(Some("yes")));
2144      assert!(env_flag_value_enabled(Some("anything")));
2145   }
2146   #[test]
2147   fn test_request_serialization() {
2148      let api_req = ApiRequest {
2149         model:            "test-model".to_string(),
2150         tools:            vec![],
2151         tool_choice:      None,
2152         prompt_cache_key: None,
2153         messages:         vec![],
2154      };
2155      let api_json = serde_json::to_string(&api_req).unwrap();
2156      assert!(!api_json.contains("max_tokens"));
2157      assert!(!api_json.contains("temperature"));
2158
2159      let anthropic_req = AnthropicRequest {
2160         model:       "test-model".to_string(),
2161         max_tokens:  16384,
2162         system:      None,
2163         tools:       vec![],
2164         tool_choice: None,
2165         messages:    vec![],
2166      };
2167      let anthropic_json = serde_json::to_string(&anthropic_req).unwrap();
2168      assert!(anthropic_json.contains("\"max_tokens\":16384"));
2169      assert!(!anthropic_json.contains("temperature"));
2170   }
2171
2172   #[test]
2173   fn test_format_llm_progress_uses_operation_label_and_request_shape() {
2174      let schema =
2175         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2176      let spec = OneShotSpec {
2177         operation:        "map-reduce/map",
2178         model:            "claude-sonnet-4.5",
2179         prompt_family:    "map",
2180         prompt_variant:   "default",
2181         system_prompt:    "system",
2182         user_prompt:      "user",
2183         tool_name:        "create_file_observation",
2184         tool_description: "Extract observations",
2185         schema:           &schema,
2186         progress_label:   Some("map file 2/5 src/lib.rs"),
2187         debug:            None,
2188         cacheable:        false,
2189      };
2190
2191      assert_eq!(
2192         format_llm_query_progress(&spec, ResolvedApiMode::ChatCompletions),
2193         "LLM query: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default, chat \
2194          completions, tool call, prompt ~3 tokens/10 chars)"
2195      );
2196      assert_eq!(
2197         format_llm_response_progress(
2198            &spec,
2199            reqwest::StatusCode::OK,
2200            std::time::Duration::from_millis(1234),
2201            2048,
2202         ),
2203         "LLM response: map file 2/5 src/lib.rs \u{2190} claude-sonnet-4.5 (HTTP 200, 1.2s, 2.0KB)"
2204      );
2205      assert_eq!(
2206         format_llm_cache_progress(&spec),
2207         "LLM cache hit: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default)"
2208      );
2209   }
2210
2211   #[test]
2212   fn test_context_length_error_detection() {
2213      assert!(is_context_length_error(
2214         r#"{"error":{"message":"Your input exceeds the context window of this model. (code=context_length_exceeded)"}}"#,
2215      ));
2216      assert!(is_context_length_error("This model's maximum context length is 128000 tokens.",));
2217      assert!(!is_context_length_error("upstream temporarily overloaded"));
2218   }
2219
2220   #[tokio::test]
2221   async fn test_retry_api_call_does_not_retry_context_length_errors() {
2222      use std::sync::atomic::{AtomicUsize, Ordering};
2223
2224      let config = CommitConfig { max_retries: 3, initial_backoff_ms: 1, ..Default::default() };
2225      let attempts = AtomicUsize::new(0);
2226
2227      let result = retry_api_call::<()>(&config, async || {
2228         attempts.fetch_add(1, Ordering::SeqCst);
2229         Err::<(bool, Option<()>), CommitGenError>(CommitGenError::ApiContextLengthExceeded {
2230            operation: "analysis".to_string(),
2231            model:     "codex".to_string(),
2232            status:    502,
2233            body:      "context_length_exceeded".to_string(),
2234         })
2235      })
2236      .await;
2237
2238      assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2239      assert_eq!(attempts.load(Ordering::SeqCst), 1);
2240   }
2241
2242   #[tokio::test]
2243   async fn test_run_oneshot_returns_context_length_error() {
2244      let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2245      let addr = listener.local_addr().unwrap();
2246      let server = std::thread::spawn(move || {
2247         use std::io::{Read, Write};
2248
2249         let (mut stream, _) = listener.accept().unwrap();
2250         let mut request = [0_u8; 4096];
2251         let _ = stream.read(&mut request);
2252         let body = r#"{"error":{"message":"context_length_exceeded"}}"#;
2253         let response = format!(
2254            "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: \
2255             {}\r\n\r\n{}",
2256            body.len(),
2257            body
2258         );
2259         stream.write_all(response.as_bytes()).unwrap();
2260      });
2261
2262      let model = "gpt-4o-mini-probe-clear-test";
2263      let config = CommitConfig {
2264         api_base_url: format!("http://{addr}"),
2265         max_retries: 3,
2266         initial_backoff_ms: 1,
2267         ..Default::default()
2268      };
2269      let schema =
2270         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2271
2272      let result = run_oneshot::<SummaryOutput>(&config, &OneShotSpec {
2273         operation: "summary",
2274         model,
2275         prompt_family: "summary",
2276         prompt_variant: "default",
2277         system_prompt: "Summarize.",
2278         user_prompt: "A large diff.",
2279         tool_name: "create_commit_summary",
2280         tool_description: "Create a commit summary",
2281         schema: &schema,
2282         progress_label: Some("summary"),
2283         debug: None,
2284         cacheable: false,
2285      })
2286      .await;
2287      assert!(result.is_err());
2288
2289      server.join().unwrap();
2290   }
2291
2292   #[test]
2293   fn test_extract_json_from_content_code_block() {
2294      let content = r#"Here is the payload:
2295
2296```json
2297{"summary":"added support"}
2298```
2299"#;
2300      assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2301   }
2302
2303   #[test]
2304   fn test_build_fast_commit_coerces_invalid_scope_output() {
2305      let commit = build_fast_commit(
2306         FastCommitOutput {
2307            commit_type: "chore".to_string(),
2308            scope:       Some(".".to_string()),
2309            summary:     "updated tooling".to_string(),
2310            details:     vec![],
2311         },
2312         &CommitConfig::default(),
2313      )
2314      .unwrap();
2315
2316      assert!(commit.scope.is_none());
2317   }
2318
2319   #[test]
2320   fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2321      let commit = build_fast_commit(
2322         FastCommitOutput {
2323            commit_type: "chore".to_string(),
2324            scope:       Some(".github/Release Notes".to_string()),
2325            summary:     "updated tooling".to_string(),
2326            details:     vec![],
2327         },
2328         &CommitConfig::default(),
2329      )
2330      .unwrap();
2331
2332      assert_eq!(
2333         commit.scope.as_ref().map(crate::types::Scope::as_str),
2334         Some("github/release-notes")
2335      );
2336   }
2337
2338   #[test]
2339   fn test_parse_oneshot_response_prefers_tool_payload() {
2340      let response_text = serde_json::json!({
2341         "choices": [{
2342            "message": {
2343               "tool_calls": [{
2344                  "function": {
2345                     "name": "create_commit_summary",
2346                     "arguments": "{\"summary\":\"added feature\"}"
2347                  }
2348               }],
2349               "content": "{\"summary\":\"ignored\"}"
2350            }
2351         }]
2352      })
2353      .to_string();
2354
2355      let result = parse_oneshot_response::<SummaryOutput>(
2356         ResolvedApiMode::ChatCompletions,
2357         "create_commit_summary",
2358         "summary",
2359         &response_text,
2360         false,
2361      );
2362
2363      match result {
2364         OneShotParseOutcome::Success(response) => {
2365            assert_eq!(response.source, OneShotSource::ToolCall);
2366            assert_eq!(response.output.summary, "added feature");
2367         },
2368         OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2369         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2370      }
2371   }
2372
2373   #[test]
2374   fn test_parse_oneshot_response_falls_back_to_content_json() {
2375      let response_text = serde_json::json!({
2376         "choices": [{
2377            "message": {
2378               "tool_calls": [{
2379                  "function": {
2380                     "name": "create_commit_summary",
2381                     "arguments": "{invalid json}"
2382                  }
2383               }],
2384               "content": "{\"summary\":\"added fallback\"}"
2385            }
2386         }]
2387      })
2388      .to_string();
2389
2390      let result = parse_oneshot_response::<SummaryOutput>(
2391         ResolvedApiMode::ChatCompletions,
2392         "create_commit_summary",
2393         "summary",
2394         &response_text,
2395         false,
2396      );
2397
2398      match result {
2399         OneShotParseOutcome::Success(response) => {
2400            assert_eq!(response.source, OneShotSource::OutputJsonParse);
2401            assert_eq!(response.output.summary, "added fallback");
2402         },
2403         OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2404         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2405      }
2406   }
2407
2408   #[test]
2409   fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2410      let response_text = serde_json::json!({
2411         "choices": [{
2412            "message": {
2413               "content": "updated gemini-image tests for CustomToolContext and array headers"
2414            }
2415         }]
2416      })
2417      .to_string();
2418
2419      let result = parse_oneshot_response::<SummaryOutput>(
2420         ResolvedApiMode::ChatCompletions,
2421         "create_commit_summary",
2422         "summary",
2423         &response_text,
2424         false,
2425      );
2426
2427      match result {
2428         OneShotParseOutcome::Success(response) => {
2429            assert_eq!(response.source, OneShotSource::PlainTextContent);
2430            assert_eq!(
2431               response.output.summary,
2432               "updated gemini-image tests for CustomToolContext and array headers"
2433            );
2434         },
2435         OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2436         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2437      }
2438   }
2439
2440   #[test]
2441   fn test_validate_summary_quality_valid() {
2442      let stat = "src/main.rs | 10 +++++++---\n";
2443      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2444      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2445      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2446   }
2447
2448   #[test]
2449   fn test_validate_summary_quality_invalid_verb() {
2450      let stat = "src/main.rs | 10 +++++++---\n";
2451      let result = validate_summary_quality("adding new feature", "feat", stat);
2452      assert!(result.is_err());
2453      assert!(result.unwrap_err().contains("past-tense verb"));
2454   }
2455
2456   #[test]
2457   fn test_validate_summary_quality_type_repetition() {
2458      let stat = "src/main.rs | 10 +++++++---\n";
2459      // "feat" is not a past-tense verb so it should fail on verb check first
2460      let result = validate_summary_quality("feat new feature", "feat", stat);
2461      assert!(result.is_err());
2462      assert!(result.unwrap_err().contains("past-tense verb"));
2463
2464      // "fixed" is past-tense but repeats "fix" type
2465      let result = validate_summary_quality("fix bug", "fix", stat);
2466      assert!(result.is_err());
2467      // "fix" is not past-tense, so fails on verb check
2468      assert!(result.unwrap_err().contains("past-tense verb"));
2469   }
2470
2471   #[test]
2472   fn test_validate_summary_quality_empty() {
2473      let stat = "src/main.rs | 10 +++++++---\n";
2474      let result = validate_summary_quality("", "feat", stat);
2475      assert!(result.is_err());
2476      assert!(result.unwrap_err().contains("empty"));
2477   }
2478
2479   #[test]
2480   fn test_validate_summary_quality_markdown_type_mismatch() {
2481      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2482      // Should warn but not fail
2483      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2484   }
2485
2486   #[test]
2487   fn test_validate_summary_quality_no_code_files() {
2488      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2489      // Should warn but not fail
2490      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2491   }
2492
2493   #[test]
2494   fn test_fallback_from_details_with_first_detail() {
2495      let config = CommitConfig::default();
2496      let details = vec![
2497         "Added authentication middleware.".to_string(),
2498         "Updated error handling.".to_string(),
2499      ];
2500      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2501      // Capital A preserved from detail
2502      assert_eq!(result.as_str(), "Added authentication middleware");
2503   }
2504
2505   #[test]
2506   fn test_fallback_from_details_strips_type_word() {
2507      let config = CommitConfig::default();
2508      let details = vec!["Featuring new oauth flow.".to_string()];
2509      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2510      // Should strip "Featuring" (present participle, not past tense) and add valid
2511      // verb
2512      assert!(result.as_str().starts_with("added"));
2513   }
2514
2515   #[test]
2516   fn test_fallback_from_details_no_details() {
2517      let config = CommitConfig::default();
2518      let details: Vec<String> = vec![];
2519      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2520      // Should use rest of summary or fallback
2521      assert!(result.as_str().starts_with("added"));
2522   }
2523
2524   #[test]
2525   fn test_fallback_from_details_adds_verb() {
2526      let config = CommitConfig::default();
2527      let details = vec!["configuration for oauth".to_string()];
2528      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2529      assert_eq!(result.as_str(), "added configuration for oauth");
2530   }
2531
2532   #[test]
2533   fn test_fallback_from_details_preserves_existing_verb() {
2534      let config = CommitConfig::default();
2535      let details = vec!["fixed authentication bug".to_string()];
2536      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2537      assert_eq!(result.as_str(), "fixed authentication bug");
2538   }
2539
2540   #[test]
2541   fn test_fallback_from_details_type_specific_verbs() {
2542      let config = CommitConfig::default();
2543      let details = vec!["module structure".to_string()];
2544
2545      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2546      assert_eq!(result.as_str(), "restructured module structure");
2547
2548      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2549      assert_eq!(result.as_str(), "documented module structure");
2550
2551      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2552      assert_eq!(result.as_str(), "tested module structure");
2553
2554      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2555      assert_eq!(result.as_str(), "optimized module structure");
2556   }
2557
2558   #[test]
2559   fn test_fallback_summary_with_stat() {
2560      let config = CommitConfig::default();
2561      let stat = "src/main.rs | 10 +++++++---\n";
2562      let details = vec![];
2563      let result = fallback_summary(stat, &details, "feat", &config);
2564      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2565   }
2566
2567   #[test]
2568   fn test_fallback_summary_with_details() {
2569      let config = CommitConfig::default();
2570      let stat = "";
2571      let details = vec!["First detail here.".to_string()];
2572      let result = fallback_summary(stat, &details, "feat", &config);
2573      // Capital F preserved
2574      assert_eq!(result.as_str(), "First detail here");
2575   }
2576
2577   #[test]
2578   fn test_fallback_summary_no_stat_no_details() {
2579      let config = CommitConfig::default();
2580      let result = fallback_summary("", &[], "feat", &config);
2581      // Fallback returns "Updated files" when no stat/details
2582      assert_eq!(result.as_str(), "Updated files");
2583   }
2584
2585   #[test]
2586   fn test_fallback_summary_type_word_overlap() {
2587      let config = CommitConfig::default();
2588      let details = vec!["refactor was performed".to_string()];
2589      let result = fallback_summary("", &details, "refactor", &config);
2590      // Should replace "refactor" with type-specific verb
2591      assert_eq!(result.as_str(), "restructured change");
2592   }
2593
2594   #[test]
2595   fn test_fallback_summary_length_limit() {
2596      let config = CommitConfig::default();
2597      let long_detail = "a ".repeat(100); // 200 chars
2598      let details = vec![long_detail.trim().to_string()];
2599      let result = fallback_summary("", &details, "feat", &config);
2600      // Should truncate to conservative max (50 chars)
2601      assert!(result.len() <= 50);
2602   }
2603}