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