Skip to main content

llm_git/
api.rs

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