Skip to main content

llm_git/
api.rs

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