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
19static TRACE_ENABLED: LazyLock<bool> =
21 LazyLock::new(|| env_flag_value_enabled(std::env::var("LLM_GIT_TRACE").ok().as_deref()));
22
23static 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
39fn 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#[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 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#[derive(Default)]
169pub struct AnalysisContext<'a> {
170 pub user_context: Option<&'a str>,
172 pub recent_commits: Option<&'a str>,
174 pub common_scopes: Option<&'a str>,
176 pub project_context: Option<&'a str>,
178 pub debug_output: Option<&'a Path>,
180 pub debug_prefix: Option<&'a str>,
182}
183
184static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
186
187pub 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
282pub 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 pub cacheable: bool,
370}
371
372#[derive(Debug)]
373pub struct OneShotResponse<T> {
374 pub output: T,
375 pub source: OneShotSource,
376 pub text_content: Option<String>,
377 pub stop_reason: Option<String>,
378}
379
380fn oneshot_progress_label<'a>(spec: &OneShotSpec<'a>) -> &'a str {
381 spec.progress_label.unwrap_or(spec.operation)
382}
383
384const fn estimate_prompt_text_tokens(spec: &OneShotSpec<'_>) -> usize {
385 spec
386 .system_prompt
387 .len()
388 .saturating_add(spec.user_prompt.len())
389 .saturating_add(3)
390 / 4
391}
392
393const fn prompt_text_chars(spec: &OneShotSpec<'_>) -> usize {
394 spec
395 .system_prompt
396 .len()
397 .saturating_add(spec.user_prompt.len())
398}
399
400fn format_count(count: usize) -> String {
401 if count >= 10_000 {
402 format!("{:.1}k", count as f64 / 1000.0)
403 } else {
404 count.to_string()
405 }
406}
407
408fn format_elapsed(elapsed: Duration) -> String {
409 if elapsed.as_secs() > 0 {
410 format!("{:.1}s", elapsed.as_secs_f64())
411 } else {
412 format!("{}ms", elapsed.as_millis())
413 }
414}
415
416fn format_bytes(bytes: usize) -> String {
417 if bytes >= 1024 * 1024 {
418 format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
419 } else if bytes >= 1024 {
420 format!("{:.1}KB", bytes as f64 / 1024.0)
421 } else {
422 format!("{bytes}B")
423 }
424}
425
426fn format_llm_query_progress(spec: &OneShotSpec<'_>, mode: ResolvedApiMode) -> String {
427 format!(
428 "LLM query: {} \u{2192} {} ({}/{}, {}, {}, prompt ~{} tokens/{} chars)",
429 oneshot_progress_label(spec),
430 spec.model,
431 spec.prompt_family,
432 spec.prompt_variant,
433 api_mode_label(mode),
434 "tool call",
435 format_count(estimate_prompt_text_tokens(spec)),
436 format_count(prompt_text_chars(spec))
437 )
438}
439
440fn format_llm_response_progress(
441 spec: &OneShotSpec<'_>,
442 status: reqwest::StatusCode,
443 elapsed: Duration,
444 body_bytes: usize,
445) -> String {
446 format!(
447 "LLM response: {} \u{2190} {} (HTTP {}, {}, {})",
448 oneshot_progress_label(spec),
449 spec.model,
450 status.as_u16(),
451 format_elapsed(elapsed),
452 format_bytes(body_bytes)
453 )
454}
455
456fn format_llm_cache_progress(spec: &OneShotSpec<'_>) -> String {
457 format!(
458 "LLM cache hit: {} \u{2192} {} ({}/{})",
459 oneshot_progress_label(spec),
460 spec.model,
461 spec.prompt_family,
462 spec.prompt_variant
463 )
464}
465
466enum OneShotRequestOutcome {
467 Response { request_json: String, response_text: String },
468 Retry,
469}
470
471enum OneShotParseOutcome<T> {
472 Success(OneShotResponse<T>),
473 Retry,
474 Fatal(CommitGenError),
475}
476
477fn save_oneshot_debug<T: Serialize>(
478 debug: Option<OneShotDebug<'_>>,
479 phase: &str,
480 value: &T,
481) -> Result<()> {
482 let Some(debug) = debug else {
483 return Ok(());
484 };
485
486 let filename = debug_filename(debug.prefix, &format!("{}_{}.json", debug.name, phase));
487 let json = serde_json::to_string_pretty(value)?;
488 save_debug_output(debug.dir, &filename, &json)
489}
490
491fn save_oneshot_debug_text(debug: Option<OneShotDebug<'_>>, phase: &str, text: &str) -> Result<()> {
492 let Some(debug) = debug else {
493 return Ok(());
494 };
495
496 let filename = debug_filename(debug.prefix, &format!("{}_{}.json", debug.name, phase));
497 save_debug_output(debug.dir, &filename, text)
498}
499
500fn schema_properties(schema: &serde_json::Value) -> Result<serde_json::Value> {
501 schema
502 .get("properties")
503 .cloned()
504 .ok_or_else(|| CommitGenError::Other("Schema must include top-level properties".to_string()))
505}
506
507fn schema_required(schema: &serde_json::Value) -> Result<Vec<String>> {
508 schema
509 .get("required")
510 .and_then(|value| value.as_array())
511 .ok_or_else(|| {
512 CommitGenError::Other("Schema must include top-level required array".to_string())
513 })
514 .and_then(|values| {
515 values
516 .iter()
517 .map(|value| {
518 value.as_str().map(str::to_string).ok_or_else(|| {
519 CommitGenError::Other("Schema required entries must be strings".to_string())
520 })
521 })
522 .collect()
523 })
524}
525
526fn build_openai_tool(
527 tool_name: &str,
528 tool_description: &str,
529 schema: &serde_json::Value,
530) -> Result<Tool> {
531 Ok(Tool {
532 tool_type: "function".to_string(),
533 function: Function {
534 name: tool_name.to_string(),
535 description: tool_description.to_string(),
536 parameters: FunctionParameters {
537 param_type: "object".to_string(),
538 properties: schema_properties(schema)?,
539 required: schema_required(schema)?,
540 },
541 },
542 })
543}
544
545fn build_anthropic_tool(
546 tool_name: &str,
547 tool_description: &str,
548 schema: &serde_json::Value,
549 prompt_caching: bool,
550) -> AnthropicTool {
551 let mut tool = AnthropicTool {
552 name: tool_name.to_string(),
553 description: tool_description.to_string(),
554 input_schema: schema.clone(),
555 cache_control: None,
556 };
557
558 if prompt_caching {
559 tool.cache_control = Some(prompt_cache_control());
560 }
561
562 tool
563}
564
565fn is_context_length_error(body: &str) -> bool {
566 let lower = body.to_lowercase();
567 [
568 "context_length_exceeded",
569 "context window",
570 "maximum context length",
571 "exceeds the context",
572 "input exceeds",
573 "prompt is too long",
574 "too many tokens",
575 ]
576 .iter()
577 .any(|needle| lower.contains(needle))
578}
579
580async fn send_oneshot_request(
581 config: &CommitConfig,
582 spec: &OneShotSpec<'_>,
583 mode: ResolvedApiMode,
584 capture_request: bool,
585) -> Result<OneShotRequestOutcome> {
586 print_llm_progress(|| format_llm_query_progress(spec, mode));
587 match mode {
588 ResolvedApiMode::ChatCompletions => {
589 let prompt_cache_key = openai_prompt_cache_key(
590 config,
591 spec.model,
592 spec.prompt_family,
593 spec.prompt_variant,
594 spec.system_prompt,
595 );
596 let mut messages = Vec::new();
597 if !spec.system_prompt.trim().is_empty() {
598 messages.push(Message {
599 role: "system".to_string(),
600 content: spec.system_prompt.to_string(),
601 });
602 }
603 messages
604 .push(Message { role: "user".to_string(), content: spec.user_prompt.to_string() });
605
606 let (tools, tool_choice) = if config.markdown_output {
609 (vec![], None)
610 } else {
611 let tool = build_openai_tool(spec.tool_name, spec.tool_description, spec.schema)?;
612 (vec![tool], Some(serde_json::json!("required")))
613 };
614
615 let request = ApiRequest {
616 model: spec.model.to_string(),
617 tools,
618 tool_choice,
619 prompt_cache_key,
620 messages,
621 };
622
623 save_oneshot_debug(spec.debug, "request", &request)?;
624
625 let client = get_client(config);
626 let mut request_builder = client
627 .post(format!("{}/chat/completions", config.api_base_url))
628 .header("content-type", "application/json");
629
630 if let Some(api_key) = &config.api_key {
631 request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
632 }
633
634 let request_json = if capture_request {
635 serde_json::to_string(&request).unwrap_or_default()
636 } else {
637 String::new()
638 };
639 let request_start = std::time::Instant::now();
640 let (status, response_text) =
641 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
642 print_llm_progress(|| {
643 format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
644 });
645 save_oneshot_debug_text(spec.debug, "response", &response_text)?;
646 if !status.is_success() && is_context_length_error(&response_text) {
647 return Err(CommitGenError::ApiContextLengthExceeded {
648 operation: spec.operation.to_string(),
649 model: spec.model.to_string(),
650 status: status.as_u16(),
651 body: response_text,
652 });
653 }
654
655 if status.is_server_error() {
656 eprintln!(
657 "{}",
658 crate::style::error(&format!("Server error {status}: {response_text}"))
659 );
660 return Ok(OneShotRequestOutcome::Retry);
661 }
662
663 if !status.is_success() {
664 return Err(CommitGenError::ApiError {
665 status: status.as_u16(),
666 body: response_text,
667 });
668 }
669
670 if response_text.trim().is_empty() {
671 crate::style::warn(&format!(
672 "Model returned empty response body for {}; retrying.",
673 spec.operation
674 ));
675 return Ok(OneShotRequestOutcome::Retry);
676 }
677
678 Ok(OneShotRequestOutcome::Response { request_json, response_text })
679 },
680 ResolvedApiMode::AnthropicMessages => {
681 let prompt_caching = anthropic_prompt_caching_enabled(config);
682 let (tools, tool_choice) = if config.markdown_output {
684 (vec![], None)
685 } else {
686 (
687 vec![build_anthropic_tool(
688 spec.tool_name,
689 spec.tool_description,
690 spec.schema,
691 prompt_caching,
692 )],
693 Some(AnthropicToolChoice {
694 choice_type: "tool".to_string(),
695 name: spec.tool_name.to_string(),
696 }),
697 )
698 };
699 const ANTHROPIC_REQUIRED_MAX_TOKENS: u32 = 16384;
702 let request = AnthropicRequest {
703 model: spec.model.to_string(),
704 max_tokens: ANTHROPIC_REQUIRED_MAX_TOKENS,
705 system: anthropic_system_content(spec.system_prompt, prompt_caching),
706 tools,
707 tool_choice,
708 messages: vec![AnthropicMessage {
709 role: "user".to_string(),
710 content: vec![anthropic_text_content(spec.user_prompt.to_string(), false)],
711 }],
712 };
713
714 save_oneshot_debug(spec.debug, "request", &request)?;
715
716 let client = get_client(config);
717 let mut request_builder = append_anthropic_cache_beta_header(
718 client
719 .post(anthropic_messages_url(&config.api_base_url))
720 .header("content-type", "application/json")
721 .header("anthropic-version", "2023-06-01"),
722 prompt_caching,
723 );
724
725 if let Some(api_key) = &config.api_key {
726 request_builder = request_builder.header("x-api-key", api_key);
727 }
728
729 let request_json = if capture_request {
730 serde_json::to_string(&request).unwrap_or_default()
731 } else {
732 String::new()
733 };
734 let request_start = std::time::Instant::now();
735 let (status, response_text) =
736 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
737 print_llm_progress(|| {
738 format_llm_response_progress(spec, status, request_start.elapsed(), response_text.len())
739 });
740 save_oneshot_debug_text(spec.debug, "response", &response_text)?;
741 if !status.is_success() && is_context_length_error(&response_text) {
742 return Err(CommitGenError::ApiContextLengthExceeded {
743 operation: spec.operation.to_string(),
744 model: spec.model.to_string(),
745 status: status.as_u16(),
746 body: response_text,
747 });
748 }
749
750 if status.is_server_error() {
751 eprintln!(
752 "{}",
753 crate::style::error(&format!("Server error {status}: {response_text}"))
754 );
755 return Ok(OneShotRequestOutcome::Retry);
756 }
757
758 if !status.is_success() {
759 return Err(CommitGenError::ApiError {
760 status: status.as_u16(),
761 body: response_text,
762 });
763 }
764
765 if response_text.trim().is_empty() {
766 crate::style::warn(&format!(
767 "Model returned empty response body for {}; retrying.",
768 spec.operation
769 ));
770 return Ok(OneShotRequestOutcome::Retry);
771 }
772
773 Ok(OneShotRequestOutcome::Response { request_json, response_text })
774 },
775 }
776}
777
778fn parse_json_output<T: DeserializeOwned>(json_text: &str, error_label: &str) -> Result<T> {
779 let candidate = extract_json_from_content(json_text);
780 serde_json::from_str(&candidate).map_err(|e| {
781 CommitGenError::Other(format!(
782 "Failed to parse {error_label}: {e}. Content: {}",
783 response_snippet(&candidate, 500)
784 ))
785 })
786}
787
788fn normalize_plain_text_content(content: &str) -> String {
789 let trimmed = content.trim();
790
791 if let Some(start) = trimmed.find("```") {
792 let after_marker = &trimmed[start + 3..];
793 let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
794 let after_newline = &after_marker[content_start..];
795 if let Some(end) = after_newline.find("```") {
796 return after_newline[..end].trim().to_string();
797 }
798 }
799
800 trimmed.to_string()
801}
802
803fn parse_plain_text_output<T: DeserializeOwned>(
804 tool_name: &str,
805 content: &str,
806 markdown_mode: bool,
807) -> Result<Option<T>> {
808 let trimmed = normalize_plain_text_content(content);
809 if trimmed.is_empty() {
810 return Ok(None);
811 }
812
813 let value = if markdown_mode {
814 match tool_name {
816 "create_conventional_analysis" => {
817 crate::markdown_output::parse_conventional_analysis(&trimmed)
818 },
819 "create_commit_summary" => crate::markdown_output::parse_summary_output(&trimmed),
820 "create_changelog_entries" => crate::markdown_output::parse_changelog_response(&trimmed),
821 "create_compose_intent_plan" => crate::markdown_output::parse_compose_intent(&trimmed),
822 "bind_compose_hunks" => crate::markdown_output::parse_compose_binding(&trimmed),
823 "create_fast_commit" => crate::markdown_output::parse_fast_commit(&trimmed),
824 "create_file_observations" => crate::markdown_output::parse_batch_observations(&trimmed),
825 _ => return Ok(None),
826 }?
827 } else {
828 match tool_name {
830 "create_commit_summary" => serde_json::json!({ "summary": trimmed }),
831 _ => return Ok(None),
832 }
833 };
834
835 serde_json::from_value(value).map(Some).map_err(|e| {
836 CommitGenError::Other(format!(
837 "Failed to parse {tool_name} plain-text fallback: {e}. Content: {}",
838 response_snippet(&trimmed, 500)
839 ))
840 })
841}
842
843fn extract_anthropic_content(
844 response_text: &str,
845 tool_name: &str,
846) -> Result<(Option<serde_json::Value>, String, Option<String>)> {
847 let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
848 CommitGenError::Other(format!(
849 "Failed to parse Anthropic response JSON: {e}. Response body: {}",
850 response_snippet(response_text, 500)
851 ))
852 })?;
853
854 let stop_reason = value
855 .get("stop_reason")
856 .and_then(|v| v.as_str())
857 .map(str::to_string);
858
859 let mut tool_input: Option<serde_json::Value> = None;
860 let mut text_parts = Vec::new();
861
862 if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
863 for item in content {
864 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
865 match item_type {
866 "tool_use" => {
867 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
868 if name == tool_name
869 && let Some(input) = item.get("input")
870 {
871 tool_input = Some(input.clone());
872 }
873 },
874 "text" => {
875 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
876 text_parts.push(text.to_string());
877 }
878 },
879 _ => {},
880 }
881 }
882 }
883
884 Ok((tool_input, text_parts.join("\n"), stop_reason))
885}
886
887fn parse_oneshot_response<T: DeserializeOwned>(
888 mode: ResolvedApiMode,
889 tool_name: &str,
890 operation: &str,
891 response_text: &str,
892 markdown_mode: bool,
893) -> OneShotParseOutcome<T> {
894 match mode {
895 ResolvedApiMode::ChatCompletions => {
896 let api_response: ApiResponse = match serde_json::from_str(response_text) {
897 Ok(response) => response,
898 Err(e) => {
899 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
900 "Failed to parse {operation} response JSON: {e}. Response body: {}",
901 response_snippet(response_text, 500)
902 )));
903 },
904 };
905
906 if api_response.choices.is_empty() {
907 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
908 "API returned empty response for {operation}"
909 )));
910 }
911
912 let message = &api_response.choices[0].message;
913 if let Some(refusal) = &message.refusal {
914 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
915 "Model refused {operation}: {refusal}"
916 )));
917 }
918
919 let mut last_error: Option<CommitGenError> = None;
920
921 if let Some(tool_call) = message.tool_calls.first()
922 && tool_call.function.name.ends_with(tool_name)
923 {
924 let args = tool_call.function.arguments.trim();
925 if args.is_empty() {
926 last_error = Some(CommitGenError::Other(format!(
927 "Model returned empty function arguments for {operation}"
928 )));
929 } else {
930 match serde_json::from_str::<T>(args) {
931 Ok(output) => {
932 return OneShotParseOutcome::Success(OneShotResponse {
933 output,
934 source: OneShotSource::ToolCall,
935 text_content: message.content.clone(),
936 stop_reason: None,
937 });
938 },
939 Err(e) => {
940 last_error = Some(CommitGenError::Other(format!(
941 "Failed to parse {operation} tool arguments: {e}. Args: {}",
942 response_snippet(args, 500)
943 )));
944 },
945 }
946 }
947 }
948
949 if let Some(content) = &message.content {
950 if content.trim().is_empty() {
951 return OneShotParseOutcome::Retry;
952 }
953
954 match parse_json_output::<T>(content, &format!("{operation} content JSON")) {
955 Ok(output) => {
956 return OneShotParseOutcome::Success(OneShotResponse {
957 output,
958 source: OneShotSource::OutputJsonParse,
959 text_content: Some(content.clone()),
960 stop_reason: None,
961 });
962 },
963 Err(err) => match parse_plain_text_output::<T>(tool_name, content, markdown_mode) {
964 Ok(Some(output)) => {
965 return OneShotParseOutcome::Success(OneShotResponse {
966 output,
967 source: OneShotSource::PlainTextContent,
968 text_content: Some(content.clone()),
969 stop_reason: None,
970 });
971 },
972 Ok(None) => last_error = Some(err),
973 Err(fallback_err) => last_error = Some(fallback_err),
974 },
975 }
976 }
977
978 OneShotParseOutcome::Fatal(last_error.unwrap_or_else(|| {
979 CommitGenError::Other(format!("No {operation} found in API response"))
980 }))
981 },
982 ResolvedApiMode::AnthropicMessages => {
983 let (tool_input, text_content, stop_reason) =
984 match extract_anthropic_content(response_text, tool_name) {
985 Ok(content) => content,
986 Err(err) => return OneShotParseOutcome::Fatal(err),
987 };
988
989 let mut last_error: Option<CommitGenError> = None;
990
991 if let Some(input) = tool_input {
992 match serde_json::from_value::<T>(input) {
993 Ok(output) => {
994 return OneShotParseOutcome::Success(OneShotResponse {
995 output,
996 source: OneShotSource::ToolCall,
997 text_content: (!text_content.is_empty()).then_some(text_content),
998 stop_reason,
999 });
1000 },
1001 Err(e) => {
1002 last_error = Some(CommitGenError::Other(format!(
1003 "Failed to parse {operation} tool input: {e}. Response body: {}",
1004 response_snippet(response_text, 500)
1005 )));
1006 },
1007 }
1008 }
1009
1010 if text_content.trim().is_empty() {
1011 return OneShotParseOutcome::Retry;
1012 }
1013
1014 match parse_json_output::<T>(&text_content, &format!("{operation} content JSON")) {
1015 Ok(output) => OneShotParseOutcome::Success(OneShotResponse {
1016 output,
1017 source: OneShotSource::OutputJsonParse,
1018 text_content: Some(text_content),
1019 stop_reason,
1020 }),
1021 Err(err) => match parse_plain_text_output::<T>(tool_name, &text_content, markdown_mode)
1022 {
1023 Ok(Some(output)) => OneShotParseOutcome::Success(OneShotResponse {
1024 output,
1025 source: OneShotSource::PlainTextContent,
1026 text_content: Some(text_content),
1027 stop_reason,
1028 }),
1029 Ok(None) => OneShotParseOutcome::Fatal(last_error.unwrap_or(err)),
1030 Err(fallback_err) => OneShotParseOutcome::Fatal(last_error.unwrap_or(fallback_err)),
1031 },
1032 }
1033 },
1034 }
1035}
1036
1037#[tracing::instrument(target = "lgit", name = "api.run_oneshot", skip_all, fields(operation = spec.operation, model = spec.model, prompt_family = spec.prompt_family, prompt_variant = spec.prompt_variant))]
1038pub async fn run_oneshot<T>(
1039 config: &CommitConfig,
1040 spec: &OneShotSpec<'_>,
1041) -> Result<OneShotResponse<T>>
1042where
1043 T: DeserializeOwned + Serialize,
1044{
1045 let cache_entry = build_cache_entry(config, spec);
1046 if let Some((cache, key)) = cache_entry.as_ref()
1047 && let Some(stored) = cache.get(key)
1048 && let Ok(output) = serde_json::from_str::<T>(&stored)
1049 {
1050 print_llm_progress(|| format_llm_cache_progress(spec));
1051 return Ok(OneShotResponse {
1052 output,
1053 source: OneShotSource::Cache,
1054 text_content: None,
1055 stop_reason: None,
1056 });
1057 }
1058 let capture_request = cache_entry.is_some();
1062 let (response, request_json): (OneShotResponse<T>, Option<String>) =
1063 retry_api_call(config, async move || {
1064 let mode = config.resolved_api_mode(spec.model);
1065
1066 let (request_json, response_text) =
1067 match send_oneshot_request(config, spec, mode, capture_request).await? {
1068 OneShotRequestOutcome::Response { request_json, response_text } => {
1069 (request_json, response_text)
1070 },
1071 OneShotRequestOutcome::Retry => return Ok((true, None)),
1072 };
1073
1074 match parse_oneshot_response::<T>(
1075 mode,
1076 spec.tool_name,
1077 spec.operation,
1078 &response_text,
1079 config.markdown_output,
1080 ) {
1081 OneShotParseOutcome::Success(output) => Ok((false, Some((output, Some(request_json))))),
1082 OneShotParseOutcome::Retry => Ok((true, None)),
1083 OneShotParseOutcome::Fatal(err) => Err(err),
1084 }
1085 })
1086 .await?;
1087
1088 if let Some((cache, key)) = cache_entry.as_ref()
1089 && let Ok(payload) = serde_json::to_string(&response.output)
1090 {
1091 cache.put(key, spec.model, spec.operation, request_json.as_deref().unwrap_or(""), &payload);
1092 }
1093
1094 Ok(response)
1095}
1096
1097fn build_cache_entry(
1098 config: &CommitConfig,
1099 spec: &OneShotSpec<'_>,
1100) -> Option<(std::sync::Arc<crate::llm_cache::LlmCache>, String)> {
1101 if !spec.cacheable {
1102 return None;
1103 }
1104 let cache = crate::llm_cache::global()?;
1105 let mode = config.resolved_api_mode(spec.model);
1106 let api_mode = match mode {
1107 ResolvedApiMode::ChatCompletions => "chat-completions",
1108 ResolvedApiMode::AnthropicMessages => "anthropic-messages",
1109 };
1110 let key = crate::llm_cache::compute_key(&crate::llm_cache::CacheMaterial {
1111 operation: spec.operation,
1112 model: spec.model,
1113 tool_name: spec.tool_name,
1114 tool_description: spec.tool_description,
1115 system_prompt: spec.system_prompt,
1116 user_prompt: spec.user_prompt,
1117 schema: spec.schema,
1118 api_mode,
1119 });
1120 Some((cache, key))
1121}
1122
1123#[derive(Debug, Serialize)]
1124struct Message {
1125 role: String,
1126 content: String,
1127}
1128
1129#[derive(Debug, Serialize, Deserialize)]
1130struct FunctionParameters {
1131 #[serde(rename = "type")]
1132 param_type: String,
1133 properties: serde_json::Value,
1134 required: Vec<String>,
1135}
1136
1137#[derive(Debug, Serialize, Deserialize)]
1138struct Function {
1139 name: String,
1140 description: String,
1141 parameters: FunctionParameters,
1142}
1143
1144#[derive(Debug, Serialize, Deserialize)]
1145struct Tool {
1146 #[serde(rename = "type")]
1147 tool_type: String,
1148 function: Function,
1149}
1150
1151#[derive(Debug, Serialize)]
1152struct ApiRequest {
1153 model: String,
1154 #[serde(skip_serializing_if = "Vec::is_empty")]
1155 tools: Vec<Tool>,
1156 #[serde(skip_serializing_if = "Option::is_none")]
1157 tool_choice: Option<serde_json::Value>,
1158 #[serde(skip_serializing_if = "Option::is_none")]
1159 prompt_cache_key: Option<String>,
1160 messages: Vec<Message>,
1161}
1162
1163#[derive(Debug, Serialize)]
1164struct AnthropicRequest {
1165 model: String,
1166 max_tokens: u32,
1167 #[serde(skip_serializing_if = "Option::is_none")]
1168 system: Option<Vec<AnthropicContent>>,
1169 #[serde(skip_serializing_if = "Vec::is_empty")]
1170 tools: Vec<AnthropicTool>,
1171 #[serde(skip_serializing_if = "Option::is_none")]
1172 tool_choice: Option<AnthropicToolChoice>,
1173 messages: Vec<AnthropicMessage>,
1174}
1175
1176#[derive(Debug, Clone, Serialize)]
1177struct PromptCacheControl {
1178 #[serde(rename = "type")]
1179 control_type: String,
1180}
1181
1182#[derive(Debug, Serialize)]
1183struct AnthropicTool {
1184 name: String,
1185 description: String,
1186 input_schema: serde_json::Value,
1187 #[serde(skip_serializing_if = "Option::is_none")]
1188 cache_control: Option<PromptCacheControl>,
1189}
1190
1191#[derive(Debug, Serialize)]
1192struct AnthropicToolChoice {
1193 #[serde(rename = "type")]
1194 choice_type: String,
1195 name: String,
1196}
1197
1198#[derive(Debug, Serialize)]
1199struct AnthropicMessage {
1200 role: String,
1201 content: Vec<AnthropicContent>,
1202}
1203
1204#[derive(Debug, Clone, Serialize)]
1205struct AnthropicContent {
1206 #[serde(rename = "type")]
1207 content_type: String,
1208 text: String,
1209 #[serde(skip_serializing_if = "Option::is_none")]
1210 cache_control: Option<PromptCacheControl>,
1211}
1212
1213#[derive(Debug, Deserialize)]
1214struct ToolCall {
1215 function: FunctionCall,
1216}
1217
1218#[derive(Debug, Deserialize)]
1219struct FunctionCall {
1220 name: String,
1221 arguments: String,
1222}
1223
1224#[derive(Debug, Deserialize)]
1225struct Choice {
1226 message: ResponseMessage,
1227}
1228
1229#[derive(Debug, Deserialize)]
1230struct ResponseMessage {
1231 #[serde(default)]
1232 tool_calls: Vec<ToolCall>,
1233 #[serde(default)]
1234 content: Option<String>,
1235 #[serde(default)]
1236 refusal: Option<String>,
1237}
1238
1239#[derive(Debug, Deserialize)]
1240struct ApiResponse {
1241 choices: Vec<Choice>,
1242}
1243
1244#[derive(Debug, Clone, Serialize, Deserialize)]
1245struct SummaryOutput {
1246 summary: String,
1247}
1248
1249#[derive(Debug, Clone, Serialize, Deserialize)]
1250struct FastCommitOutput {
1251 #[serde(rename = "type")]
1252 commit_type: String,
1253 #[serde(default)]
1254 scope: Option<String>,
1255 summary: String,
1256 #[serde(default)]
1257 details: Vec<String>,
1258}
1259
1260const fn should_retry_error(error: &CommitGenError) -> bool {
1261 !matches!(error, CommitGenError::ApiContextLengthExceeded { .. })
1262}
1263#[tracing::instrument(target = "lgit", name = "api.retry", skip_all, fields(max_retries = config.max_retries))]
1265pub async fn retry_api_call<T>(
1266 config: &CommitConfig,
1267 mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
1268) -> Result<T> {
1269 let mut attempt = 0;
1270
1271 loop {
1272 attempt += 1;
1273 if crate::profile::enabled() {
1274 tracing::info!(
1275 target: crate::profile::TARGET,
1276 event = "api_retry_attempt_started",
1277 attempt,
1278 max_retries = config.max_retries,
1279 );
1280 }
1281
1282 match f().await {
1283 Ok((false, Some(result))) => return Ok(result),
1284 Ok((false, None)) => {
1285 return Err(CommitGenError::Other("API call failed without result".to_string()));
1286 },
1287 Ok((true, _)) if attempt < config.max_retries => {
1288 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1289 if crate::profile::enabled() {
1290 tracing::warn!(
1291 target: crate::profile::TARGET,
1292 event = "api_retry_scheduled",
1293 attempt,
1294 max_retries = config.max_retries,
1295 backoff_ms,
1296 reason = "retryable_response",
1297 );
1298 }
1299 eprintln!(
1300 "{}",
1301 crate::style::warning(&format!(
1302 "Retry {}/{} after {}ms...",
1303 attempt, config.max_retries, backoff_ms
1304 ))
1305 );
1306 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1307 },
1308 Ok((true, _last_err)) => {
1309 return Err(CommitGenError::ApiRetryExhausted {
1310 retries: config.max_retries,
1311 source: Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
1312 });
1313 },
1314 Err(e) => {
1315 if !should_retry_error(&e) {
1316 return Err(e);
1317 }
1318
1319 if attempt < config.max_retries {
1320 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1321 if crate::profile::enabled() {
1322 tracing::warn!(
1323 target: crate::profile::TARGET,
1324 event = "api_retry_scheduled",
1325 attempt,
1326 max_retries = config.max_retries,
1327 backoff_ms,
1328 reason = "error",
1329 error = %e,
1330 );
1331 }
1332 eprintln!(
1333 "{}",
1334 crate::style::warning(&format!(
1335 "Error: {} - Retry {}/{} after {}ms...",
1336 e, attempt, config.max_retries, backoff_ms
1337 ))
1338 );
1339 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1340 continue;
1341 }
1342 return Err(e);
1343 },
1344 }
1345 }
1346}
1347
1348pub fn format_types_description(config: &CommitConfig) -> String {
1351 use std::fmt::Write;
1352 let mut out = String::from("Check types in order (first match wins):\n\n");
1353
1354 for (name, tc) in &config.types {
1355 let _ = writeln!(out, "**{name}**: {}", tc.description);
1356 if !tc.diff_indicators.is_empty() {
1357 let _ = writeln!(out, " Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
1358 }
1359 if !tc.file_patterns.is_empty() {
1360 let _ = writeln!(out, " File patterns: {}", tc.file_patterns.join(", "));
1361 }
1362 for ex in &tc.examples {
1363 let _ = writeln!(out, " - {ex}");
1364 }
1365 if !tc.hint.is_empty() {
1366 let _ = writeln!(out, " Note: {}", tc.hint);
1367 }
1368 out.push('\n');
1369 }
1370
1371 if !config.classifier_hint.is_empty() {
1372 let _ = writeln!(out, "\n{}", config.classifier_hint);
1373 }
1374
1375 out
1376}
1377
1378#[tracing::instrument(target = "lgit", name = "api.generate_conventional_analysis", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1380pub async fn generate_conventional_analysis<'a>(
1381 stat: &'a str,
1382 diff: &'a str,
1383 model_name: &'a str,
1384 scope_candidates_str: &'a str,
1385 ctx: &AnalysisContext<'a>,
1386 config: &'a CommitConfig,
1387) -> Result<ConventionalAnalysis> {
1388 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1389
1390 let analysis_schema = strict_json_schema(
1391 serde_json::json!({
1392 "type": {
1393 "type": "string",
1394 "enum": type_enum,
1395 "description": "Commit type based on change classification"
1396 },
1397 "scope": {
1398 "type": "string",
1399 "description": "Optional scope (module/component). Omit if unclear or multi-component."
1400 },
1401 "summary": {
1402 "type": "string",
1403 "description": format!(
1404 "Umbrella commit summary without type/scope prefix or trailing period; target {} chars, hard limit {}.",
1405 config.summary_guideline,
1406 config.summary_hard_limit
1407 ),
1408 "maxLength": config.summary_hard_limit
1409 },
1410 "details": {
1411 "type": "array",
1412 "description": "Array of 0-6 detail items with changelog metadata.",
1413 "items": {
1414 "type": "object",
1415 "properties": {
1416 "text": {
1417 "type": "string",
1418 "description": "Detail about change, starting with past-tense verb, ending with period"
1419 },
1420 "changelog_category": {
1421 "type": "string",
1422 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
1423 "description": "Changelog category if user-visible. Omit for internal changes."
1424 },
1425 "user_visible": {
1426 "type": "boolean",
1427 "description": "True if this change affects users/API and should appear in changelog"
1428 }
1429 },
1430 "required": ["text", "user_visible"]
1431 }
1432 },
1433 "issue_refs": {
1434 "type": "array",
1435 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
1436 "items": { "type": "string" }
1437 }
1438 }),
1439 &["type", "summary", "details", "issue_refs"],
1440 );
1441
1442 let prompt_variant = if config.markdown_output {
1443 "markdown"
1444 } else {
1445 &config.analysis_prompt_variant
1446 };
1447
1448 let types_desc = format_types_description(config);
1449 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
1450 variant: prompt_variant,
1451 stat,
1452 diff,
1453 scope_candidates: scope_candidates_str,
1454 recent_commits: ctx.recent_commits,
1455 common_scopes: ctx.common_scopes,
1456 types_description: Some(&types_desc),
1457 project_context: ctx.project_context,
1458 })?;
1459
1460 let user_prompt = if let Some(user_ctx) = ctx.user_context {
1461 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
1462 } else {
1463 parts.user
1464 };
1465
1466 let response = run_oneshot::<ConventionalAnalysis>(config, &OneShotSpec {
1467 operation: "analysis",
1468 model: model_name,
1469 prompt_family: "analysis",
1470 prompt_variant,
1471 system_prompt: &parts.system,
1472 user_prompt: &user_prompt,
1473 tool_name: "create_conventional_analysis",
1474 tool_description: "Analyze changes and classify as conventional commit with type, scope, \
1475 summary, details, and metadata",
1476 schema: &analysis_schema,
1477 progress_label: Some("analysis"),
1478 debug: Some(OneShotDebug {
1479 dir: ctx.debug_output,
1480 prefix: ctx.debug_prefix,
1481 name: "analysis",
1482 }),
1483 cacheable: true,
1484 })
1485 .await?;
1486
1487 Ok(response.output)
1488}
1489
1490pub fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
1500 let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
1501 let prefix = format!("{commit_type}{scope_part}: ");
1502
1503 if let Some(stripped) = summary.strip_prefix(&prefix) {
1504 return stripped.to_string();
1505 }
1506
1507 let prefix_no_scope = format!("{commit_type}: ");
1509 if let Some(stripped) = summary.strip_prefix(&prefix_no_scope) {
1510 return stripped.to_string();
1511 }
1512
1513 let summary_lower = summary.to_ascii_lowercase();
1517 let commit_lower = commit_type.to_ascii_lowercase();
1518
1519 let generic_prefix = format!("{commit_lower}(");
1521 if let Some(after_type) = summary_lower.strip_prefix(&generic_prefix) {
1522 if let Some(close) = after_type.find("): ") {
1523 return summary[commit_type.len() + 1 + close + 3..].to_string();
1524 }
1525 if let Some(close) = after_type.find("):") {
1526 return summary[commit_type.len() + 1 + close + 2..]
1527 .trim_start()
1528 .to_string();
1529 }
1530 }
1531
1532 let prefix_no_scope_lower = format!("{commit_lower}: ");
1534 if summary_lower.starts_with(&prefix_no_scope_lower) {
1535 return summary[commit_type.len() + 2..].to_string();
1536 }
1537
1538 summary.to_string()
1539}
1540
1541pub fn summary_from_holistic_analysis(
1546 analysis: &ConventionalAnalysis,
1547 config: &CommitConfig,
1548) -> Result<Option<CommitSummary>> {
1549 let Some(raw_summary) = analysis
1550 .summary
1551 .as_deref()
1552 .map(str::trim)
1553 .filter(|summary| !summary.is_empty())
1554 else {
1555 return Ok(None);
1556 };
1557
1558 let cleaned = strip_type_prefix(
1559 raw_summary,
1560 analysis.commit_type.as_str(),
1561 analysis.scope.as_ref().map(|scope| scope.as_str()),
1562 );
1563
1564 CommitSummary::new(cleaned, config.summary_hard_limit).map(Some)
1565}
1566
1567fn validate_summary_quality(
1569 summary: &str,
1570 commit_type: &str,
1571 stat: &str,
1572) -> std::result::Result<(), String> {
1573 use crate::validation::is_past_tense_first_word;
1574
1575 let first_word = summary
1576 .split_whitespace()
1577 .next()
1578 .ok_or_else(|| "summary is empty".to_string())?;
1579
1580 let first_word_lower = first_word.to_lowercase();
1581
1582 if !is_past_tense_first_word(first_word) {
1585 return Err(format!(
1586 "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
1587 ));
1588 }
1589
1590 if first_word_lower == commit_type {
1592 return Err(format!("repeats commit type '{commit_type}' in summary"));
1593 }
1594
1595 let file_exts: Vec<&str> = stat
1597 .lines()
1598 .filter_map(|line| {
1599 let path = line.split('|').next()?.trim();
1600 std::path::Path::new(path).extension()?.to_str()
1601 })
1602 .collect();
1603
1604 if !file_exts.is_empty() {
1605 let total = file_exts.len();
1606 let md_count = file_exts.iter().filter(|&&e| e == "md").count();
1607
1608 if md_count * 100 / total > 80 && commit_type != "docs" {
1610 crate::style::warn(&format!(
1611 "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
1612 md_count * 100 / total,
1613 commit_type
1614 ));
1615 }
1616
1617 let code_exts = [
1619 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
1621 "java", "kt", "kts", "scala", "groovy", "clj", "cljs", "cs", "fs", "vb", "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", "py", "pyx", "pxd", "pyi", "rb", "rake", "gemspec", "php", "go", "swift", "m", "mm", "lua", "sh", "bash", "zsh", "fish", "pl", "pm", "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
1634 "lisp", "cl", "el", "scm", "rkt", "jl", "r", "R", "dart", "cr", "d", "f", "f90", "f95", "f03", "f08", "ada", "adb", "ads", "cob", "cbl", "asm", "s", "S", "sql", "plsql", "pl", "pro", "re", "rei", "nix", "tf", "hcl", "sol", "move", "cairo",
1653 ];
1654 let code_count = file_exts
1655 .iter()
1656 .filter(|&&e| code_exts.contains(&e))
1657 .count();
1658 if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
1659 crate::style::warn(&format!(
1660 "Type mismatch: no code files changed but type is '{commit_type}'"
1661 ));
1662 }
1663 }
1664
1665 Ok(())
1666}
1667
1668#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
1670#[tracing::instrument(target = "lgit", name = "api.generate_summary_from_analysis", skip_all, fields(commit_type, scope = ?scope, detail_count = details.len(), model = %config.summary_model))]
1671pub async fn generate_summary_from_analysis<'a>(
1672 stat: &'a str,
1673 commit_type: &'a str,
1674 scope: Option<&'a str>,
1675 details: &'a [String],
1676 user_context: Option<&'a str>,
1677 config: &'a CommitConfig,
1678 debug_dir: Option<&'a Path>,
1679 debug_prefix: Option<&'a str>,
1680) -> Result<CommitSummary> {
1681 let mut validation_attempt = 0;
1682 let max_validation_retries = 1;
1683 let mut last_failure_reason: Option<String> = None;
1684
1685 loop {
1686 let additional_constraint = if let Some(reason) = &last_failure_reason {
1687 format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1688 } else {
1689 String::new()
1690 };
1691
1692 let bullet_points = details.join("\n");
1693 let details_str = if bullet_points.is_empty() {
1694 "None (no supporting detail points were generated)."
1695 } else {
1696 bullet_points.as_str()
1697 };
1698
1699 let scope_str = scope.unwrap_or("");
1700 let prefix_len =
1701 commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 };
1702 let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
1703
1704 let summary_variant = if config.markdown_output {
1705 "markdown"
1706 } else {
1707 &config.summary_prompt_variant
1708 };
1709
1710 let parts = templates::render_summary_prompt(
1711 summary_variant,
1712 commit_type,
1713 scope_str,
1714 &max_summary_len.to_string(),
1715 details_str,
1716 stat.trim(),
1717 user_context,
1718 )?;
1719
1720 let user_prompt = format!("{}{additional_constraint}", parts.user);
1721 let summary_schema = strict_json_schema(
1722 serde_json::json!({
1723 "summary": {
1724 "type": "string",
1725 "description": format!(
1726 "Single line summary, target {} chars (hard limit {}), past tense verb first.",
1727 config.summary_guideline,
1728 config.summary_hard_limit
1729 ),
1730 "maxLength": config.summary_hard_limit
1731 }
1732 }),
1733 &["summary"],
1734 );
1735
1736 let response = run_oneshot::<SummaryOutput>(config, &OneShotSpec {
1737 operation: "summary",
1738 model: &config.summary_model,
1739 prompt_family: "summary",
1740 prompt_variant: summary_variant,
1741 system_prompt: &parts.system,
1742 user_prompt: &user_prompt,
1743 tool_name: "create_commit_summary",
1744 tool_description: "Compose a git commit summary line from detail statements",
1745 schema: &summary_schema,
1746 progress_label: Some("summary"),
1747 debug: Some(OneShotDebug {
1748 dir: debug_dir,
1749 prefix: debug_prefix,
1750 name: "summary",
1751 }),
1752 cacheable: true,
1753 })
1754 .await;
1755
1756 match response {
1757 Ok(response) => {
1758 let cleaned = strip_type_prefix(&response.output.summary, commit_type, scope);
1759 let mut normalized = cleaned;
1762 crate::normalization::normalize_summary_verb(&mut normalized, commit_type);
1763 let summary = CommitSummary::new(&normalized, config.summary_hard_limit)?;
1764
1765 match validate_summary_quality(summary.as_str(), commit_type, stat) {
1766 Ok(()) => return Ok(summary),
1767 Err(reason) if validation_attempt < max_validation_retries => {
1768 crate::style::warn(&format!(
1769 "Validation failed (attempt {}/{}): {}",
1770 validation_attempt + 1,
1771 max_validation_retries + 1,
1772 reason
1773 ));
1774 last_failure_reason = Some(reason);
1775 validation_attempt += 1;
1776 },
1777 Err(reason) => {
1778 crate::style::warn(&format!(
1779 "Validation failed after {} retries: {}. Using fallback.",
1780 max_validation_retries + 1,
1781 reason
1782 ));
1783 return Ok(fallback_from_details_or_summary(
1784 details,
1785 summary.as_str(),
1786 commit_type,
1787 config,
1788 ));
1789 },
1790 }
1791 },
1792 Err(e) => return Err(e),
1793 }
1794 }
1795}
1796
1797fn fallback_from_details_or_summary(
1799 details: &[String],
1800 invalid_summary: &str,
1801 commit_type: &str,
1802 config: &CommitConfig,
1803) -> CommitSummary {
1804 let candidate = if let Some(first_detail) = details.first() {
1805 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1807
1808 let type_word_variants =
1810 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1811 for variant in &type_word_variants {
1812 if cleaned
1813 .to_lowercase()
1814 .starts_with(&format!("{} ", variant.to_lowercase()))
1815 {
1816 cleaned = cleaned[variant.len()..].trim().to_string();
1817 break;
1818 }
1819 }
1820
1821 cleaned
1822 } else {
1823 let mut cleaned = invalid_summary
1825 .split_whitespace()
1826 .skip(1) .collect::<Vec<_>>()
1828 .join(" ");
1829
1830 if cleaned.is_empty() {
1831 cleaned = fallback_summary("", details, commit_type, config)
1832 .as_str()
1833 .to_string();
1834 }
1835
1836 cleaned
1837 };
1838
1839 let with_verb = if candidate
1841 .split_whitespace()
1842 .next()
1843 .is_some_and(crate::validation::is_past_tense_first_word)
1844 {
1845 candidate
1846 } else {
1847 let verb = match commit_type {
1848 "feat" => "added",
1849 "fix" => "fixed",
1850 "refactor" => "restructured",
1851 "docs" => "documented",
1852 "test" => "tested",
1853 "perf" => "optimized",
1854 "build" | "ci" | "chore" => "updated",
1855 "style" => "formatted",
1856 "revert" => "reverted",
1857 _ => "changed",
1858 };
1859 format!("{verb} {candidate}")
1860 };
1861
1862 CommitSummary::new(with_verb, config.summary_hard_limit)
1863 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1864}
1865
1866pub fn fallback_summary(
1868 stat: &str,
1869 details: &[String],
1870 commit_type: &str,
1871 config: &CommitConfig,
1872) -> CommitSummary {
1873 let mut candidate = if let Some(first) = details.first() {
1874 first.trim().trim_end_matches('.').to_string()
1875 } else {
1876 let primary_line = stat
1877 .lines()
1878 .map(str::trim)
1879 .find(|line| !line.is_empty())
1880 .unwrap_or("files");
1881
1882 let subject = primary_line
1883 .split('|')
1884 .next()
1885 .map(str::trim)
1886 .filter(|s| !s.is_empty())
1887 .unwrap_or("files");
1888
1889 if subject.eq_ignore_ascii_case("files") {
1890 "Updated files".to_string()
1891 } else {
1892 format!("Updated {subject}")
1893 }
1894 };
1895
1896 candidate = candidate
1897 .replace(['\n', '\r'], " ")
1898 .split_whitespace()
1899 .collect::<Vec<_>>()
1900 .join(" ")
1901 .trim()
1902 .trim_end_matches('.')
1903 .trim_end_matches(';')
1904 .trim_end_matches(':')
1905 .to_string();
1906
1907 if candidate.is_empty() {
1908 candidate = "Updated files".to_string();
1909 }
1910
1911 const CONSERVATIVE_MAX: usize = 50;
1914 while candidate.len() > CONSERVATIVE_MAX {
1915 if let Some(pos) = candidate.rfind(' ') {
1916 candidate.truncate(pos);
1917 candidate = candidate.trim_end_matches(',').trim().to_string();
1918 } else {
1919 candidate.truncate(CONSERVATIVE_MAX);
1920 break;
1921 }
1922 }
1923
1924 candidate = candidate.trim_end_matches('.').to_string();
1926
1927 if candidate
1930 .split_whitespace()
1931 .next()
1932 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1933 {
1934 candidate = match commit_type {
1935 "refactor" => "restructured change".to_string(),
1936 "feat" => "added functionality".to_string(),
1937 "fix" => "fixed issue".to_string(),
1938 "docs" => "documented updates".to_string(),
1939 "test" => "tested changes".to_string(),
1940 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1941 "perf" => "optimized performance".to_string(),
1942 "revert" => "reverted previous commit".to_string(),
1943 _ => "updated files".to_string(),
1944 };
1945 }
1946
1947 CommitSummary::new(candidate, config.summary_hard_limit)
1950 .expect("fallback summary should always be valid")
1951}
1952
1953#[tracing::instrument(target = "lgit", name = "api.generate_analysis_with_map_reduce", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1958pub async fn generate_analysis_with_map_reduce<'a>(
1959 stat: &'a str,
1960 diff: &'a str,
1961 model_name: &'a str,
1962 scope_candidates_str: &'a str,
1963 ctx: &AnalysisContext<'a>,
1964 config: &'a CommitConfig,
1965 counter: &TokenCounter,
1966) -> Result<ConventionalAnalysis> {
1967 use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1968
1969 if should_use_map_reduce(diff, config, counter) {
1970 crate::style::print_info(&format!(
1971 "Large diff detected ({} tokens), using map-reduce...",
1972 counter.count_sync(diff)
1973 ));
1974 run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1975 } else {
1976 generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1977 .await
1978 }
1979}
1980
1981#[tracing::instrument(target = "lgit", name = "api.generate_fast_commit", skip_all, fields(model = model_name, diff_bytes = diff.len(), stat_bytes = stat.len()))]
1985pub async fn generate_fast_commit(
1986 stat: &str,
1987 diff: &str,
1988 model_name: &str,
1989 scope_candidates_str: &str,
1990 user_context: Option<&str>,
1991 config: &CommitConfig,
1992 debug_dir: Option<&Path>,
1993) -> Result<ConventionalCommit> {
1994 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1995 let types_desc = format_types_description(config);
1996
1997 let fast_variant = if config.markdown_output {
1998 "markdown"
1999 } else {
2000 "default"
2001 };
2002 let parts = templates::render_fast_prompt(&templates::FastPromptParams {
2003 variant: fast_variant,
2004 stat,
2005 diff,
2006 scope_candidates: scope_candidates_str,
2007 user_context,
2008 types_description: Some(&types_desc),
2009 })?;
2010
2011 let fast_schema = strict_json_schema(
2012 serde_json::json!({
2013 "type": {
2014 "type": "string",
2015 "enum": type_enum,
2016 "description": "Conventional commit type"
2017 },
2018 "scope": {
2019 "type": "string",
2020 "description": "Optional scope. Omit if unclear or cross-cutting."
2021 },
2022 "summary": {
2023 "type": "string",
2024 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
2025 },
2026 "details": {
2027 "type": "array",
2028 "items": { "type": "string" },
2029 "description": "0-3 past-tense detail sentences ending with period"
2030 }
2031 }),
2032 &["type", "summary", "details"],
2033 );
2034
2035 let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
2036 operation: "fast",
2037 model: model_name,
2038 prompt_family: "fast",
2039 prompt_variant: fast_variant,
2040 system_prompt: &parts.system,
2041 user_prompt: &parts.user,
2042 tool_name: "create_fast_commit",
2043 tool_description: "Generate a conventional commit from the given diff",
2044 schema: &fast_schema,
2045 progress_label: Some("fast commit"),
2046 debug: Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
2047 cacheable: true,
2048 })
2049 .await?;
2050
2051 build_fast_commit(response.output, config)
2052}
2053
2054fn build_fast_commit(
2056 output: FastCommitOutput,
2057 config: &CommitConfig,
2058) -> Result<ConventionalCommit> {
2059 let commit_type = CommitType::new(&output.commit_type)?;
2060 let scope = coerce_optional_scope(output.scope.as_deref());
2061 let cleaned_summary =
2062 strip_type_prefix(&output.summary, commit_type.as_str(), scope.as_ref().map(|s| s.as_str()));
2063 let summary = CommitSummary::new(&cleaned_summary, config.summary_hard_limit)?;
2064 Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2065}
2066#[cfg(test)]
2067mod tests {
2068 use super::*;
2069 use crate::config::CommitConfig;
2070
2071 #[test]
2072 fn test_strip_type_prefix_exact_scope() {
2073 assert_eq!(strip_type_prefix("fix(api): fixed bug", "fix", Some("api")), "fixed bug");
2074 }
2075
2076 #[test]
2077 fn test_strip_type_prefix_no_scope() {
2078 assert_eq!(strip_type_prefix("fix: fixed bug", "fix", None), "fixed bug");
2079 }
2080
2081 #[test]
2082 fn test_strip_type_prefix_different_scope() {
2083 assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", None), "fixed bug");
2086 assert_eq!(strip_type_prefix("fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2088 }
2089
2090 #[test]
2091 fn test_strip_type_prefix_no_prefix() {
2092 assert_eq!(strip_type_prefix("fixed bug", "fix", None), "fixed bug");
2094 }
2095
2096 #[test]
2097 fn test_strip_type_prefix_wrong_type_not_stripped() {
2098 assert_eq!(
2100 strip_type_prefix("feat(api): added feature", "fix", None),
2101 "feat(api): added feature"
2102 );
2103 }
2104
2105 #[test]
2106 fn test_strip_type_prefix_capitalized_type_with_scope() {
2107 assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", None), "fixed bug");
2109 assert_eq!(strip_type_prefix("Fix(tui): fixed bug", "fix", Some("api")), "fixed bug");
2110 }
2111
2112 #[test]
2113 fn test_strip_type_prefix_capitalized_type_no_scope() {
2114 assert_eq!(strip_type_prefix("Feat: added feature", "feat", None), "added feature");
2116 }
2117
2118 #[test]
2119 fn test_strip_type_prefix_uppercase_type() {
2120 assert_eq!(strip_type_prefix("FIX(api): fixed bug", "fix", Some("api")), "fixed bug");
2122 }
2123
2124 #[test]
2125 fn test_strict_json_schema_disallows_extra_properties() {
2126 let schema =
2127 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2128 assert_eq!(schema["type"], "object");
2129 assert_eq!(schema["required"], serde_json::json!(["summary"]));
2130 assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2131 }
2132
2133 #[test]
2134 fn test_env_flag_value_enabled_uses_boolean_semantics() {
2135 assert!(!env_flag_value_enabled(None));
2136 assert!(!env_flag_value_enabled(Some("")));
2137 assert!(!env_flag_value_enabled(Some("0")));
2138 assert!(!env_flag_value_enabled(Some("false")));
2139 assert!(!env_flag_value_enabled(Some("NO")));
2140 assert!(!env_flag_value_enabled(Some("off")));
2141 assert!(env_flag_value_enabled(Some("1")));
2142 assert!(env_flag_value_enabled(Some("true")));
2143 assert!(env_flag_value_enabled(Some("yes")));
2144 assert!(env_flag_value_enabled(Some("anything")));
2145 }
2146 #[test]
2147 fn test_request_serialization() {
2148 let api_req = ApiRequest {
2149 model: "test-model".to_string(),
2150 tools: vec![],
2151 tool_choice: None,
2152 prompt_cache_key: None,
2153 messages: vec![],
2154 };
2155 let api_json = serde_json::to_string(&api_req).unwrap();
2156 assert!(!api_json.contains("max_tokens"));
2157 assert!(!api_json.contains("temperature"));
2158
2159 let anthropic_req = AnthropicRequest {
2160 model: "test-model".to_string(),
2161 max_tokens: 16384,
2162 system: None,
2163 tools: vec![],
2164 tool_choice: None,
2165 messages: vec![],
2166 };
2167 let anthropic_json = serde_json::to_string(&anthropic_req).unwrap();
2168 assert!(anthropic_json.contains("\"max_tokens\":16384"));
2169 assert!(!anthropic_json.contains("temperature"));
2170 }
2171
2172 #[test]
2173 fn test_format_llm_progress_uses_operation_label_and_request_shape() {
2174 let schema =
2175 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2176 let spec = OneShotSpec {
2177 operation: "map-reduce/map",
2178 model: "claude-sonnet-4.5",
2179 prompt_family: "map",
2180 prompt_variant: "default",
2181 system_prompt: "system",
2182 user_prompt: "user",
2183 tool_name: "create_file_observation",
2184 tool_description: "Extract observations",
2185 schema: &schema,
2186 progress_label: Some("map file 2/5 src/lib.rs"),
2187 debug: None,
2188 cacheable: false,
2189 };
2190
2191 assert_eq!(
2192 format_llm_query_progress(&spec, ResolvedApiMode::ChatCompletions),
2193 "LLM query: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default, chat \
2194 completions, tool call, prompt ~3 tokens/10 chars)"
2195 );
2196 assert_eq!(
2197 format_llm_response_progress(
2198 &spec,
2199 reqwest::StatusCode::OK,
2200 std::time::Duration::from_millis(1234),
2201 2048,
2202 ),
2203 "LLM response: map file 2/5 src/lib.rs \u{2190} claude-sonnet-4.5 (HTTP 200, 1.2s, 2.0KB)"
2204 );
2205 assert_eq!(
2206 format_llm_cache_progress(&spec),
2207 "LLM cache hit: map file 2/5 src/lib.rs \u{2192} claude-sonnet-4.5 (map/default)"
2208 );
2209 }
2210
2211 #[test]
2212 fn test_context_length_error_detection() {
2213 assert!(is_context_length_error(
2214 r#"{"error":{"message":"Your input exceeds the context window of this model. (code=context_length_exceeded)"}}"#,
2215 ));
2216 assert!(is_context_length_error("This model's maximum context length is 128000 tokens.",));
2217 assert!(!is_context_length_error("upstream temporarily overloaded"));
2218 }
2219
2220 #[tokio::test]
2221 async fn test_retry_api_call_does_not_retry_context_length_errors() {
2222 use std::sync::atomic::{AtomicUsize, Ordering};
2223
2224 let config = CommitConfig { max_retries: 3, initial_backoff_ms: 1, ..Default::default() };
2225 let attempts = AtomicUsize::new(0);
2226
2227 let result = retry_api_call::<()>(&config, async || {
2228 attempts.fetch_add(1, Ordering::SeqCst);
2229 Err::<(bool, Option<()>), CommitGenError>(CommitGenError::ApiContextLengthExceeded {
2230 operation: "analysis".to_string(),
2231 model: "codex".to_string(),
2232 status: 502,
2233 body: "context_length_exceeded".to_string(),
2234 })
2235 })
2236 .await;
2237
2238 assert!(matches!(result, Err(CommitGenError::ApiContextLengthExceeded { .. })));
2239 assert_eq!(attempts.load(Ordering::SeqCst), 1);
2240 }
2241
2242 #[tokio::test]
2243 async fn test_run_oneshot_returns_context_length_error() {
2244 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
2245 let addr = listener.local_addr().unwrap();
2246 let server = std::thread::spawn(move || {
2247 use std::io::{Read, Write};
2248
2249 let (mut stream, _) = listener.accept().unwrap();
2250 let mut request = [0_u8; 4096];
2251 let _ = stream.read(&mut request);
2252 let body = r#"{"error":{"message":"context_length_exceeded"}}"#;
2253 let response = format!(
2254 "HTTP/1.1 400 Bad Request\r\ncontent-type: application/json\r\ncontent-length: \
2255 {}\r\n\r\n{}",
2256 body.len(),
2257 body
2258 );
2259 stream.write_all(response.as_bytes()).unwrap();
2260 });
2261
2262 let model = "gpt-4o-mini-probe-clear-test";
2263 let config = CommitConfig {
2264 api_base_url: format!("http://{addr}"),
2265 max_retries: 3,
2266 initial_backoff_ms: 1,
2267 ..Default::default()
2268 };
2269 let schema =
2270 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2271
2272 let result = run_oneshot::<SummaryOutput>(&config, &OneShotSpec {
2273 operation: "summary",
2274 model,
2275 prompt_family: "summary",
2276 prompt_variant: "default",
2277 system_prompt: "Summarize.",
2278 user_prompt: "A large diff.",
2279 tool_name: "create_commit_summary",
2280 tool_description: "Create a commit summary",
2281 schema: &schema,
2282 progress_label: Some("summary"),
2283 debug: None,
2284 cacheable: false,
2285 })
2286 .await;
2287 assert!(result.is_err());
2288
2289 server.join().unwrap();
2290 }
2291
2292 #[test]
2293 fn test_extract_json_from_content_code_block() {
2294 let content = r#"Here is the payload:
2295
2296```json
2297{"summary":"added support"}
2298```
2299"#;
2300 assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2301 }
2302
2303 #[test]
2304 fn test_build_fast_commit_coerces_invalid_scope_output() {
2305 let commit = build_fast_commit(
2306 FastCommitOutput {
2307 commit_type: "chore".to_string(),
2308 scope: Some(".".to_string()),
2309 summary: "updated tooling".to_string(),
2310 details: vec![],
2311 },
2312 &CommitConfig::default(),
2313 )
2314 .unwrap();
2315
2316 assert!(commit.scope.is_none());
2317 }
2318
2319 #[test]
2320 fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2321 let commit = build_fast_commit(
2322 FastCommitOutput {
2323 commit_type: "chore".to_string(),
2324 scope: Some(".github/Release Notes".to_string()),
2325 summary: "updated tooling".to_string(),
2326 details: vec![],
2327 },
2328 &CommitConfig::default(),
2329 )
2330 .unwrap();
2331
2332 assert_eq!(
2333 commit.scope.as_ref().map(crate::types::Scope::as_str),
2334 Some("github/release-notes")
2335 );
2336 }
2337
2338 #[test]
2339 fn test_parse_oneshot_response_prefers_tool_payload() {
2340 let response_text = serde_json::json!({
2341 "choices": [{
2342 "message": {
2343 "tool_calls": [{
2344 "function": {
2345 "name": "create_commit_summary",
2346 "arguments": "{\"summary\":\"added feature\"}"
2347 }
2348 }],
2349 "content": "{\"summary\":\"ignored\"}"
2350 }
2351 }]
2352 })
2353 .to_string();
2354
2355 let result = parse_oneshot_response::<SummaryOutput>(
2356 ResolvedApiMode::ChatCompletions,
2357 "create_commit_summary",
2358 "summary",
2359 &response_text,
2360 false,
2361 );
2362
2363 match result {
2364 OneShotParseOutcome::Success(response) => {
2365 assert_eq!(response.source, OneShotSource::ToolCall);
2366 assert_eq!(response.output.summary, "added feature");
2367 },
2368 OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2369 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2370 }
2371 }
2372
2373 #[test]
2374 fn test_parse_oneshot_response_falls_back_to_content_json() {
2375 let response_text = serde_json::json!({
2376 "choices": [{
2377 "message": {
2378 "tool_calls": [{
2379 "function": {
2380 "name": "create_commit_summary",
2381 "arguments": "{invalid json}"
2382 }
2383 }],
2384 "content": "{\"summary\":\"added fallback\"}"
2385 }
2386 }]
2387 })
2388 .to_string();
2389
2390 let result = parse_oneshot_response::<SummaryOutput>(
2391 ResolvedApiMode::ChatCompletions,
2392 "create_commit_summary",
2393 "summary",
2394 &response_text,
2395 false,
2396 );
2397
2398 match result {
2399 OneShotParseOutcome::Success(response) => {
2400 assert_eq!(response.source, OneShotSource::OutputJsonParse);
2401 assert_eq!(response.output.summary, "added fallback");
2402 },
2403 OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2404 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2405 }
2406 }
2407
2408 #[test]
2409 fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2410 let response_text = serde_json::json!({
2411 "choices": [{
2412 "message": {
2413 "content": "updated gemini-image tests for CustomToolContext and array headers"
2414 }
2415 }]
2416 })
2417 .to_string();
2418
2419 let result = parse_oneshot_response::<SummaryOutput>(
2420 ResolvedApiMode::ChatCompletions,
2421 "create_commit_summary",
2422 "summary",
2423 &response_text,
2424 false,
2425 );
2426
2427 match result {
2428 OneShotParseOutcome::Success(response) => {
2429 assert_eq!(response.source, OneShotSource::PlainTextContent);
2430 assert_eq!(
2431 response.output.summary,
2432 "updated gemini-image tests for CustomToolContext and array headers"
2433 );
2434 },
2435 OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2436 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2437 }
2438 }
2439
2440 #[test]
2441 fn test_validate_summary_quality_valid() {
2442 let stat = "src/main.rs | 10 +++++++---\n";
2443 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2444 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2445 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2446 }
2447
2448 #[test]
2449 fn test_validate_summary_quality_invalid_verb() {
2450 let stat = "src/main.rs | 10 +++++++---\n";
2451 let result = validate_summary_quality("adding new feature", "feat", stat);
2452 assert!(result.is_err());
2453 assert!(result.unwrap_err().contains("past-tense verb"));
2454 }
2455
2456 #[test]
2457 fn test_validate_summary_quality_type_repetition() {
2458 let stat = "src/main.rs | 10 +++++++---\n";
2459 let result = validate_summary_quality("feat new feature", "feat", stat);
2461 assert!(result.is_err());
2462 assert!(result.unwrap_err().contains("past-tense verb"));
2463
2464 let result = validate_summary_quality("fix bug", "fix", stat);
2466 assert!(result.is_err());
2467 assert!(result.unwrap_err().contains("past-tense verb"));
2469 }
2470
2471 #[test]
2472 fn test_validate_summary_quality_empty() {
2473 let stat = "src/main.rs | 10 +++++++---\n";
2474 let result = validate_summary_quality("", "feat", stat);
2475 assert!(result.is_err());
2476 assert!(result.unwrap_err().contains("empty"));
2477 }
2478
2479 #[test]
2480 fn test_validate_summary_quality_markdown_type_mismatch() {
2481 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2482 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2484 }
2485
2486 #[test]
2487 fn test_validate_summary_quality_no_code_files() {
2488 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2489 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2491 }
2492
2493 #[test]
2494 fn test_fallback_from_details_with_first_detail() {
2495 let config = CommitConfig::default();
2496 let details = vec![
2497 "Added authentication middleware.".to_string(),
2498 "Updated error handling.".to_string(),
2499 ];
2500 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2501 assert_eq!(result.as_str(), "Added authentication middleware");
2503 }
2504
2505 #[test]
2506 fn test_fallback_from_details_strips_type_word() {
2507 let config = CommitConfig::default();
2508 let details = vec!["Featuring new oauth flow.".to_string()];
2509 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2510 assert!(result.as_str().starts_with("added"));
2513 }
2514
2515 #[test]
2516 fn test_fallback_from_details_no_details() {
2517 let config = CommitConfig::default();
2518 let details: Vec<String> = vec![];
2519 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2520 assert!(result.as_str().starts_with("added"));
2522 }
2523
2524 #[test]
2525 fn test_fallback_from_details_adds_verb() {
2526 let config = CommitConfig::default();
2527 let details = vec!["configuration for oauth".to_string()];
2528 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2529 assert_eq!(result.as_str(), "added configuration for oauth");
2530 }
2531
2532 #[test]
2533 fn test_fallback_from_details_preserves_existing_verb() {
2534 let config = CommitConfig::default();
2535 let details = vec!["fixed authentication bug".to_string()];
2536 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2537 assert_eq!(result.as_str(), "fixed authentication bug");
2538 }
2539
2540 #[test]
2541 fn test_fallback_from_details_type_specific_verbs() {
2542 let config = CommitConfig::default();
2543 let details = vec!["module structure".to_string()];
2544
2545 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2546 assert_eq!(result.as_str(), "restructured module structure");
2547
2548 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2549 assert_eq!(result.as_str(), "documented module structure");
2550
2551 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2552 assert_eq!(result.as_str(), "tested module structure");
2553
2554 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2555 assert_eq!(result.as_str(), "optimized module structure");
2556 }
2557
2558 #[test]
2559 fn test_fallback_summary_with_stat() {
2560 let config = CommitConfig::default();
2561 let stat = "src/main.rs | 10 +++++++---\n";
2562 let details = vec![];
2563 let result = fallback_summary(stat, &details, "feat", &config);
2564 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2565 }
2566
2567 #[test]
2568 fn test_fallback_summary_with_details() {
2569 let config = CommitConfig::default();
2570 let stat = "";
2571 let details = vec!["First detail here.".to_string()];
2572 let result = fallback_summary(stat, &details, "feat", &config);
2573 assert_eq!(result.as_str(), "First detail here");
2575 }
2576
2577 #[test]
2578 fn test_fallback_summary_no_stat_no_details() {
2579 let config = CommitConfig::default();
2580 let result = fallback_summary("", &[], "feat", &config);
2581 assert_eq!(result.as_str(), "Updated files");
2583 }
2584
2585 #[test]
2586 fn test_fallback_summary_type_word_overlap() {
2587 let config = CommitConfig::default();
2588 let details = vec!["refactor was performed".to_string()];
2589 let result = fallback_summary("", &details, "refactor", &config);
2590 assert_eq!(result.as_str(), "restructured change");
2592 }
2593
2594 #[test]
2595 fn test_fallback_summary_length_limit() {
2596 let config = CommitConfig::default();
2597 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
2599 let result = fallback_summary("", &details, "feat", &config);
2600 assert!(result.len() <= 50);
2602 }
2603}