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