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