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_conventional_analysis(&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 parts = templates::render_fast_prompt(&templates::FastPromptParams {
2010      variant: "default",
2011      stat,
2012      diff,
2013      scope_candidates: scope_candidates_str,
2014      user_context,
2015      types_description: Some(&types_desc),
2016   })?;
2017
2018   let fast_schema = strict_json_schema(
2019      serde_json::json!({
2020         "type": {
2021            "type": "string",
2022            "enum": type_enum,
2023            "description": "Conventional commit type"
2024         },
2025         "scope": {
2026            "type": "string",
2027            "description": "Optional scope. Omit if unclear or cross-cutting."
2028         },
2029         "summary": {
2030            "type": "string",
2031            "description": "≤72 char past-tense summary, no type prefix, no trailing period"
2032         },
2033         "details": {
2034            "type": "array",
2035            "items": { "type": "string" },
2036            "description": "0-3 past-tense detail sentences ending with period"
2037         }
2038      }),
2039      &["type", "summary", "details"],
2040   );
2041
2042   let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
2043      operation:        "fast",
2044      model:            model_name,
2045      prompt_family:    "fast",
2046      prompt_variant:   "default",
2047      system_prompt:    &parts.system,
2048      user_prompt:      &parts.user,
2049      tool_name:        "create_fast_commit",
2050      tool_description: "Generate a conventional commit from the given diff",
2051      schema:           &fast_schema,
2052      progress_label:   Some("fast commit"),
2053      debug:            Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
2054      cacheable:        true,
2055   })
2056   .await?;
2057
2058   build_fast_commit(response.output, config)
2059}
2060
2061/// Convert a `FastCommitOutput` into a validated `ConventionalCommit`.
2062fn build_fast_commit(
2063   output: FastCommitOutput,
2064   config: &CommitConfig,
2065) -> Result<ConventionalCommit> {
2066   let commit_type = CommitType::new(&output.commit_type)?;
2067   let scope = coerce_optional_scope(output.scope.as_deref());
2068   let cleaned_summary = strip_type_prefix(
2069      &output.summary,
2070      commit_type.as_str(),
2071      scope.as_ref().map(|s| s.as_str()),
2072   );
2073   let summary = CommitSummary::new(&cleaned_summary, config.summary_hard_limit)?;
2074   Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2075}
2076#[cfg(test)]
2077mod tests {
2078   use super::*;
2079   use crate::config::CommitConfig;
2080
2081   #[test]
2082   fn test_strip_type_prefix_exact_scope() {
2083      assert_eq!(strip_type_prefix("fix(api): fixed bug", "fix", Some("api")), "fixed bug");
2084   }
2085
2086   #[test]
2087   fn test_strip_type_prefix_no_scope() {
2088      assert_eq!(strip_type_prefix("fix: fixed bug", "fix", None), "fixed bug");
2089   }
2090
2091   #[test]
2092   fn test_strip_type_prefix_different_scope() {
2093      // Model emits scope we didn't parse (e.g. scope is None but model wrote fix(tui):)
2094      assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", None), "fixed bug");
2095      // Model emits different scope than parsed
2096      assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2097   }
2098
2099   #[test]
2100   fn test_strip_type_prefix_no_prefix() {
2101      // No prefix present — should return unchanged
2102      assert_eq!(strip_type_prefix("fixed bug", "fix", None), "fixed bug");
2103   }
2104
2105   #[test]
2106   fn test_strip_type_prefix_wrong_type_not_stripped() {
2107      // Should not strip a prefix with a different type
2108      assert_eq!(strip_type_prefix("feat(api): added feature", "fix", None), "feat(api): added feature");
2109   }
2110
2111   #[test]
2112   fn test_strip_type_prefix_capitalized_type_with_scope() {
2113      // Model emits `Fix(tui):` with capital F
2114      assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", None), "fixed bug");
2115      assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2116   }
2117
2118   #[test]
2119   fn test_strip_type_prefix_capitalized_type_no_scope() {
2120      // Model emits `Feat: ` with capital F
2121      assert_eq!(strip_type_prefix("Feat: added feature", "feat", None), "added feature");
2122   }
2123
2124   #[test]
2125   fn test_strip_type_prefix_uppercase_type() {
2126      // Model emits `FIX(api):` all caps
2127      assert_eq!(strip_type_prefix("FIX(api): fixed bug", "fix", Some("api")), "fixed bug");
2128   }
2129
2130   #[test]
2131   fn test_strict_json_schema_disallows_extra_properties() {
2132      let schema =
2133         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2134      assert_eq!(schema["type"], "object");
2135      assert_eq!(schema["required"], serde_json::json!(["summary"]));
2136      assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2137   }
2138
2139   #[test]
2140   fn test_env_flag_value_enabled_uses_boolean_semantics() {
2141      assert!(!env_flag_value_enabled(None));
2142      assert!(!env_flag_value_enabled(Some("")));
2143      assert!(!env_flag_value_enabled(Some("0")));
2144      assert!(!env_flag_value_enabled(Some("false")));
2145      assert!(!env_flag_value_enabled(Some("NO")));
2146      assert!(!env_flag_value_enabled(Some("off")));
2147      assert!(env_flag_value_enabled(Some("1")));
2148      assert!(env_flag_value_enabled(Some("true")));
2149      assert!(env_flag_value_enabled(Some("yes")));
2150      assert!(env_flag_value_enabled(Some("anything")));
2151   }
2152   #[test]
2153   fn test_request_serialization() {
2154      let api_req = ApiRequest {
2155         model: "test-model".to_string(),
2156         tools: vec![],
2157         tool_choice: None,
2158         prompt_cache_key: None,
2159         messages: vec![],
2160      };
2161      let api_json = serde_json::to_string(&api_req).unwrap();
2162      assert!(!api_json.contains("max_tokens"));
2163      assert!(!api_json.contains("temperature"));
2164
2165      let anthropic_req = AnthropicRequest {
2166         model: "test-model".to_string(),
2167         max_tokens: 16384,
2168         system: None,
2169         tools: vec![],
2170         tool_choice: None,
2171         messages: vec![],
2172      };
2173      let anthropic_json = serde_json::to_string(&anthropic_req).unwrap();
2174      assert!(anthropic_json.contains("\"max_tokens\":16384"));
2175      assert!(!anthropic_json.contains("temperature"));
2176   }
2177
2178   #[test]
2179   fn test_format_llm_progress_uses_operation_label_and_request_shape() {
2180      let schema =
2181         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2182      let spec = OneShotSpec {
2183         operation:        "map-reduce/map",
2184         model:            "claude-sonnet-4.5",
2185         prompt_family:    "map",
2186         prompt_variant:   "default",
2187         system_prompt:    "system",
2188         user_prompt:      "user",
2189         tool_name:        "create_file_observation",
2190         tool_description: "Extract observations",
2191         schema:           &schema,
2192         progress_label:   Some("map file 2/5 src/lib.rs"),
2193         debug:            None,
2194         cacheable:        false,
2195      };
2196
2197      assert_eq!(
2198         format_llm_query_progress(&spec, ResolvedApiMode::ChatCompletions),
2199         "LLM query: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default, chat \
2200          completions, tool call, prompt ~3 tokens/10 chars)"
2201      );
2202      assert_eq!(
2203         format_llm_response_progress(
2204            &spec,
2205            reqwest::StatusCode::OK,
2206            std::time::Duration::from_millis(1234),
2207            2048,
2208         ),
2209         "LLM response: map file 2/5 src/lib.rs \u{2190} claude-sonnet-4.5 (HTTP 200, 1.2s, 2.0KB)"
2210      );
2211      assert_eq!(
2212         format_llm_cache_progress(&spec),
2213         "LLM cache hit: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default)"
2214      );
2215   }
2216
2217   #[test]
2218   fn test_context_length_error_detection() {
2219      assert!(is_context_length_error(
2220         r#"{"error":{"message":"Your input exceeds the context window of this model. (code=context_length_exceeded)"}}"#,
2221      ));
2222      assert!(is_context_length_error("This model's maximum context length is 128000 tokens.",));
2223      assert!(!is_context_length_error("upstream temporarily overloaded"));
2224   }
2225
2226   #[tokio::test]
2227   async fn test_retry_api_call_does_not_retry_context_length_errors() {
2228      use std::sync::atomic::{AtomicUsize, Ordering};
2229
2230      let config = CommitConfig { max_retries: 3, initial_backoff_ms: 1, ..Default::default() };
2231      let attempts = AtomicUsize::new(0);
2232
2233      let result = retry_api_call::<()>(&config, async || {
2234         attempts.fetch_add(1, Ordering::SeqCst);
2235         Err::<(bool, Option<()>), CommitGenError>(CommitGenError::ApiContextLengthExceeded {
2236            operation: "analysis".to_string(),
2237            model:     "codex".to_string(),
2238            status:    502,
2239            body:      "context_length_exceeded".to_string(),
2240         })
2241      })
2242      .await;
2243
2244      assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2245      assert_eq!(attempts.load(Ordering::SeqCst), 1);
2246   }
2247
2248   #[tokio::test]
2249   async fn test_run_oneshot_returns_context_length_error() {
2250      let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2251      let addr = listener.local_addr().unwrap();
2252      let server = std::thread::spawn(move || {
2253         use std::io::{Read, Write};
2254
2255         let (mut stream, _) = listener.accept().unwrap();
2256         let mut request = [0_u8; 4096];
2257         let _ = stream.read(&mut request);
2258         let body = r#"{"error":{"message":"context_length_exceeded"}}"#;
2259         let response = format!(
2260            "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: \
2261             {}\r\n\r\n{}",
2262            body.len(),
2263            body
2264         );
2265         stream.write_all(response.as_bytes()).unwrap();
2266      });
2267
2268      let model = "gpt-4o-mini-probe-clear-test";
2269      let config = CommitConfig {
2270         api_base_url: format!("http://{addr}"),
2271         max_retries: 3,
2272         initial_backoff_ms: 1,
2273         ..Default::default()
2274      };
2275      let schema =
2276         strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2277
2278      let result = run_oneshot::<SummaryOutput>(&config, &OneShotSpec {
2279         operation: "summary",
2280         model,
2281         prompt_family: "summary",
2282         prompt_variant: "default",
2283         system_prompt: "Summarize.",
2284         user_prompt: "A large diff.",
2285         tool_name: "create_commit_summary",
2286         tool_description: "Create a commit summary",
2287         schema: &schema,
2288         progress_label: Some("summary"),
2289         debug: None,
2290         cacheable: false,
2291      })
2292      .await;
2293      assert!(result.is_err());
2294
2295      server.join().unwrap();
2296   }
2297
2298   #[test]
2299   fn test_extract_json_from_content_code_block() {
2300      let content = r#"Here is the payload:
2301
2302```json
2303{"summary":"added support"}
2304```
2305"#;
2306      assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2307   }
2308
2309   #[test]
2310   fn test_build_fast_commit_coerces_invalid_scope_output() {
2311      let commit = build_fast_commit(
2312         FastCommitOutput {
2313            commit_type: "chore".to_string(),
2314            scope:       Some(".".to_string()),
2315            summary:     "updated tooling".to_string(),
2316            details:     vec![],
2317         },
2318         &CommitConfig::default(),
2319      )
2320      .unwrap();
2321
2322      assert!(commit.scope.is_none());
2323   }
2324
2325   #[test]
2326   fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2327      let commit = build_fast_commit(
2328         FastCommitOutput {
2329            commit_type: "chore".to_string(),
2330            scope:       Some(".github/Release Notes".to_string()),
2331            summary:     "updated tooling".to_string(),
2332            details:     vec![],
2333         },
2334         &CommitConfig::default(),
2335      )
2336      .unwrap();
2337
2338      assert_eq!(
2339         commit.scope.as_ref().map(crate::types::Scope::as_str),
2340         Some("github/release-notes")
2341      );
2342   }
2343
2344   #[test]
2345   fn test_parse_oneshot_response_prefers_tool_payload() {
2346      let response_text = serde_json::json!({
2347         "choices": [{
2348            "message": {
2349               "tool_calls": [{
2350                  "function": {
2351                     "name": "create_commit_summary",
2352                     "arguments": "{\"summary\":\"added feature\"}"
2353                  }
2354               }],
2355               "content": "{\"summary\":\"ignored\"}"
2356            }
2357         }]
2358      })
2359      .to_string();
2360
2361      let result = parse_oneshot_response::<SummaryOutput>(
2362         ResolvedApiMode::ChatCompletions,
2363         "create_commit_summary",
2364         "summary",
2365         &response_text,
2366         false,
2367      );
2368
2369      match result {
2370         OneShotParseOutcome::Success(response) => {
2371            assert_eq!(response.source, OneShotSource::ToolCall);
2372            assert_eq!(response.output.summary, "added feature");
2373         },
2374         OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2375         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2376      }
2377   }
2378
2379   #[test]
2380   fn test_parse_oneshot_response_falls_back_to_content_json() {
2381      let response_text = serde_json::json!({
2382         "choices": [{
2383            "message": {
2384               "tool_calls": [{
2385                  "function": {
2386                     "name": "create_commit_summary",
2387                     "arguments": "{invalid json}"
2388                  }
2389               }],
2390               "content": "{\"summary\":\"added fallback\"}"
2391            }
2392         }]
2393      })
2394      .to_string();
2395
2396      let result = parse_oneshot_response::<SummaryOutput>(
2397         ResolvedApiMode::ChatCompletions,
2398         "create_commit_summary",
2399         "summary",
2400         &response_text,
2401         false,
2402      );
2403
2404      match result {
2405         OneShotParseOutcome::Success(response) => {
2406            assert_eq!(response.source, OneShotSource::OutputJsonParse);
2407            assert_eq!(response.output.summary, "added fallback");
2408         },
2409         OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2410         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2411      }
2412   }
2413
2414   #[test]
2415   fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2416      let response_text = serde_json::json!({
2417         "choices": [{
2418            "message": {
2419               "content": "updated gemini-image tests for CustomToolContext and array headers"
2420            }
2421         }]
2422      })
2423      .to_string();
2424
2425      let result = parse_oneshot_response::<SummaryOutput>(
2426         ResolvedApiMode::ChatCompletions,
2427         "create_commit_summary",
2428         "summary",
2429         &response_text,
2430         false,
2431      );
2432
2433      match result {
2434         OneShotParseOutcome::Success(response) => {
2435            assert_eq!(response.source, OneShotSource::PlainTextContent);
2436            assert_eq!(
2437               response.output.summary,
2438               "updated gemini-image tests for CustomToolContext and array headers"
2439            );
2440         },
2441         OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2442         OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2443      }
2444   }
2445
2446   #[test]
2447   fn test_validate_summary_quality_valid() {
2448      let stat = "src/main.rs | 10 +++++++---\n";
2449      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2450      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2451      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2452   }
2453
2454   #[test]
2455   fn test_validate_summary_quality_invalid_verb() {
2456      let stat = "src/main.rs | 10 +++++++---\n";
2457      let result = validate_summary_quality("adding new feature", "feat", stat);
2458      assert!(result.is_err());
2459      assert!(result.unwrap_err().contains("past-tense verb"));
2460   }
2461
2462   #[test]
2463   fn test_validate_summary_quality_type_repetition() {
2464      let stat = "src/main.rs | 10 +++++++---\n";
2465      // "feat" is not a past-tense verb so it should fail on verb check first
2466      let result = validate_summary_quality("feat new feature", "feat", stat);
2467      assert!(result.is_err());
2468      assert!(result.unwrap_err().contains("past-tense verb"));
2469
2470      // "fixed" is past-tense but repeats "fix" type
2471      let result = validate_summary_quality("fix bug", "fix", stat);
2472      assert!(result.is_err());
2473      // "fix" is not past-tense, so fails on verb check
2474      assert!(result.unwrap_err().contains("past-tense verb"));
2475   }
2476
2477   #[test]
2478   fn test_validate_summary_quality_empty() {
2479      let stat = "src/main.rs | 10 +++++++---\n";
2480      let result = validate_summary_quality("", "feat", stat);
2481      assert!(result.is_err());
2482      assert!(result.unwrap_err().contains("empty"));
2483   }
2484
2485   #[test]
2486   fn test_validate_summary_quality_markdown_type_mismatch() {
2487      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2488      // Should warn but not fail
2489      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2490   }
2491
2492   #[test]
2493   fn test_validate_summary_quality_no_code_files() {
2494      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2495      // Should warn but not fail
2496      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2497   }
2498
2499   #[test]
2500   fn test_fallback_from_details_with_first_detail() {
2501      let config = CommitConfig::default();
2502      let details = vec![
2503         "Added authentication middleware.".to_string(),
2504         "Updated error handling.".to_string(),
2505      ];
2506      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2507      // Capital A preserved from detail
2508      assert_eq!(result.as_str(), "Added authentication middleware");
2509   }
2510
2511   #[test]
2512   fn test_fallback_from_details_strips_type_word() {
2513      let config = CommitConfig::default();
2514      let details = vec!["Featuring new oauth flow.".to_string()];
2515      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2516      // Should strip "Featuring" (present participle, not past tense) and add valid
2517      // verb
2518      assert!(result.as_str().starts_with("added"));
2519   }
2520
2521   #[test]
2522   fn test_fallback_from_details_no_details() {
2523      let config = CommitConfig::default();
2524      let details: Vec<String> = vec![];
2525      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2526      // Should use rest of summary or fallback
2527      assert!(result.as_str().starts_with("added"));
2528   }
2529
2530   #[test]
2531   fn test_fallback_from_details_adds_verb() {
2532      let config = CommitConfig::default();
2533      let details = vec!["configuration for oauth".to_string()];
2534      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2535      assert_eq!(result.as_str(), "added configuration for oauth");
2536   }
2537
2538   #[test]
2539   fn test_fallback_from_details_preserves_existing_verb() {
2540      let config = CommitConfig::default();
2541      let details = vec!["fixed authentication bug".to_string()];
2542      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2543      assert_eq!(result.as_str(), "fixed authentication bug");
2544   }
2545
2546   #[test]
2547   fn test_fallback_from_details_type_specific_verbs() {
2548      let config = CommitConfig::default();
2549      let details = vec!["module structure".to_string()];
2550
2551      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2552      assert_eq!(result.as_str(), "restructured module structure");
2553
2554      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2555      assert_eq!(result.as_str(), "documented module structure");
2556
2557      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2558      assert_eq!(result.as_str(), "tested module structure");
2559
2560      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2561      assert_eq!(result.as_str(), "optimized module structure");
2562   }
2563
2564   #[test]
2565   fn test_fallback_summary_with_stat() {
2566      let config = CommitConfig::default();
2567      let stat = "src/main.rs | 10 +++++++---\n";
2568      let details = vec![];
2569      let result = fallback_summary(stat, &details, "feat", &config);
2570      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2571   }
2572
2573   #[test]
2574   fn test_fallback_summary_with_details() {
2575      let config = CommitConfig::default();
2576      let stat = "";
2577      let details = vec!["First detail here.".to_string()];
2578      let result = fallback_summary(stat, &details, "feat", &config);
2579      // Capital F preserved
2580      assert_eq!(result.as_str(), "First detail here");
2581   }
2582
2583   #[test]
2584   fn test_fallback_summary_no_stat_no_details() {
2585      let config = CommitConfig::default();
2586      let result = fallback_summary("", &[], "feat", &config);
2587      // Fallback returns "Updated files" when no stat/details
2588      assert_eq!(result.as_str(), "Updated files");
2589   }
2590
2591   #[test]
2592   fn test_fallback_summary_type_word_overlap() {
2593      let config = CommitConfig::default();
2594      let details = vec!["refactor was performed".to_string()];
2595      let result = fallback_summary("", &details, "refactor", &config);
2596      // Should replace "refactor" with type-specific verb
2597      assert_eq!(result.as_str(), "restructured change");
2598   }
2599
2600   #[test]
2601   fn test_fallback_summary_length_limit() {
2602      let config = CommitConfig::default();
2603      let long_detail = "a ".repeat(100); // 200 chars
2604      let details = vec![long_detail.trim().to_string()];
2605      let result = fallback_summary("", &details, "feat", &config);
2606      // Should truncate to conservative max (50 chars)
2607      assert!(result.len() <= 50);
2608   }
2609}