1use std::{path::Path, sync::OnceLock, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6 config::{CommitConfig, ResolvedApiMode},
7 error::{CommitGenError, Result},
8 templates,
9 tokens::TokenCounter,
10 types::{CommitSummary, CommitType, ConventionalAnalysis, ConventionalCommit, Scope},
11};
12
13static TRACE_ENABLED: OnceLock<bool> = OnceLock::new();
15
16fn trace_enabled() -> bool {
18 *TRACE_ENABLED.get_or_init(|| std::env::var("LLM_GIT_TRACE").is_ok())
19}
20
21pub async fn timed_send(
26 request_builder: reqwest::RequestBuilder,
27 label: &str,
28 model: &str,
29) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
30 let trace = trace_enabled();
31 let start = std::time::Instant::now();
32
33 let response = request_builder
34 .send()
35 .await
36 .map_err(CommitGenError::HttpError)?;
37
38 let ttft = start.elapsed();
39 let status = response.status();
40 let content_length = response.content_length();
41
42 let body = response.text().await.map_err(CommitGenError::HttpError)?;
43 let total = start.elapsed();
44
45 if trace {
46 let size_info = content_length.map_or_else(
47 || format!("{}B", body.len()),
48 |cl| format!("{}B (content-length: {cl})", body.len()),
49 );
50 if !crate::style::pipe_mode() {
52 print!("\r\x1b[K");
53 std::io::Write::flush(&mut std::io::stdout()).ok();
54 }
55 eprintln!(
56 "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
57 body={size_info}"
58 );
59 }
60
61 Ok((status, body))
62}
63
64#[derive(Default)]
68pub struct AnalysisContext<'a> {
69 pub user_context: Option<&'a str>,
71 pub recent_commits: Option<&'a str>,
73 pub common_scopes: Option<&'a str>,
75 pub project_context: Option<&'a str>,
77 pub debug_output: Option<&'a Path>,
79 pub debug_prefix: Option<&'a str>,
81}
82
83static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
85
86pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
91 CLIENT.get_or_init(|| {
92 reqwest::Client::builder()
93 .timeout(Duration::from_secs(config.request_timeout_secs))
94 .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
95 .build()
96 .expect("Failed to build HTTP client")
97 })
98}
99
100fn debug_filename(prefix: Option<&str>, name: &str) -> String {
101 match prefix {
102 Some(p) if !p.is_empty() => format!("{p}_{name}"),
103 _ => name.to_string(),
104 }
105}
106
107fn response_snippet(body: &str, limit: usize) -> String {
108 if body.is_empty() {
109 return "<empty response body>".to_string();
110 }
111 let mut snippet = body.trim().to_string();
112 if snippet.len() > limit {
113 snippet.truncate(limit);
114 snippet.push_str("...");
115 }
116 snippet
117}
118
119fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
120 let Some(dir) = debug_dir else {
121 return Ok(());
122 };
123
124 std::fs::create_dir_all(dir)?;
125 let path = dir.join(filename);
126 std::fs::write(&path, content)?;
127 Ok(())
128}
129
130fn anthropic_messages_url(base_url: &str) -> String {
131 let trimmed = base_url.trim_end_matches('/');
132 if trimmed.ends_with("/v1") {
133 format!("{trimmed}/messages")
134 } else {
135 format!("{trimmed}/v1/messages")
136 }
137}
138
139fn prompt_cache_control() -> PromptCacheControl {
140 PromptCacheControl { control_type: "ephemeral".to_string() }
141}
142
143fn anthropic_prompt_caching_enabled(config: &CommitConfig) -> bool {
144 config.api_base_url.to_lowercase().contains("anthropic.com")
145}
146
147fn append_anthropic_cache_beta_header(
148 request_builder: reqwest::RequestBuilder,
149 enable_cache: bool,
150) -> reqwest::RequestBuilder {
151 if enable_cache {
152 request_builder.header("anthropic-beta", "prompt-caching-2024-07-31")
153 } else {
154 request_builder
155 }
156}
157
158fn anthropic_text_content(text: String, cache: bool) -> AnthropicContent {
159 AnthropicContent {
160 content_type: "text".to_string(),
161 text,
162 cache_control: cache.then(prompt_cache_control),
163 }
164}
165
166fn anthropic_system_content(system_prompt: &str, cache: bool) -> Option<Vec<AnthropicContent>> {
167 if system_prompt.trim().is_empty() {
168 None
169 } else {
170 Some(vec![anthropic_text_content(system_prompt.to_string(), cache)])
171 }
172}
173
174fn cache_last_anthropic_tool(tools: &mut [AnthropicTool], cache: bool) {
175 if cache && let Some(last) = tools.last_mut() {
176 last.cache_control = Some(prompt_cache_control());
177 }
178}
179
180fn supports_openai_prompt_cache_key(config: &CommitConfig) -> bool {
181 config
182 .api_base_url
183 .to_lowercase()
184 .contains("api.openai.com")
185}
186
187pub fn openai_prompt_cache_key(
189 config: &CommitConfig,
190 model_name: &str,
191 prompt_family: &str,
192 prompt_variant: &str,
193 system_prompt: &str,
194) -> Option<String> {
195 if system_prompt.trim().is_empty() || !supports_openai_prompt_cache_key(config) {
196 return None;
197 }
198
199 Some(format!("llm-git:v1:{model_name}:{prompt_family}:{prompt_variant}"))
200}
201
202fn extract_anthropic_content(
203 response_text: &str,
204 tool_name: &str,
205) -> Result<(Option<serde_json::Value>, String)> {
206 let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
207 CommitGenError::Other(format!(
208 "Failed to parse Anthropic response JSON: {e}. Response body: {}",
209 response_snippet(response_text, 500)
210 ))
211 })?;
212
213 let mut tool_input: Option<serde_json::Value> = None;
214 let mut text_parts = Vec::new();
215
216 if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
217 for item in content {
218 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
219 match item_type {
220 "tool_use" => {
221 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
222 if name == tool_name
223 && let Some(input) = item.get("input")
224 {
225 tool_input = Some(input.clone());
226 }
227 },
228 "text" => {
229 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
230 text_parts.push(text.to_string());
231 }
232 },
233 _ => {},
234 }
235 }
236 }
237
238 Ok((tool_input, text_parts.join("\n")))
239}
240
241#[derive(Debug, Serialize)]
242struct Message {
243 role: String,
244 content: String,
245}
246
247#[derive(Debug, Serialize, Deserialize)]
248struct FunctionParameters {
249 #[serde(rename = "type")]
250 param_type: String,
251 properties: serde_json::Value,
252 required: Vec<String>,
253}
254
255#[derive(Debug, Serialize, Deserialize)]
256struct Function {
257 name: String,
258 description: String,
259 parameters: FunctionParameters,
260}
261
262#[derive(Debug, Serialize, Deserialize)]
263struct Tool {
264 #[serde(rename = "type")]
265 tool_type: String,
266 function: Function,
267}
268
269#[derive(Debug, Serialize)]
270struct ApiRequest {
271 model: String,
272 max_tokens: u32,
273 temperature: f32,
274 tools: Vec<Tool>,
275 #[serde(skip_serializing_if = "Option::is_none")]
276 tool_choice: Option<serde_json::Value>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 prompt_cache_key: Option<String>,
279 messages: Vec<Message>,
280}
281
282#[derive(Debug, Serialize)]
283struct AnthropicRequest {
284 model: String,
285 max_tokens: u32,
286 temperature: f32,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 system: Option<Vec<AnthropicContent>>,
289 tools: Vec<AnthropicTool>,
290 #[serde(skip_serializing_if = "Option::is_none")]
291 tool_choice: Option<AnthropicToolChoice>,
292 messages: Vec<AnthropicMessage>,
293}
294
295#[derive(Debug, Clone, Serialize)]
296struct PromptCacheControl {
297 #[serde(rename = "type")]
298 control_type: String,
299}
300
301#[derive(Debug, Serialize)]
302struct AnthropicTool {
303 name: String,
304 description: String,
305 input_schema: serde_json::Value,
306 #[serde(skip_serializing_if = "Option::is_none")]
307 cache_control: Option<PromptCacheControl>,
308}
309
310#[derive(Debug, Serialize)]
311struct AnthropicToolChoice {
312 #[serde(rename = "type")]
313 choice_type: String,
314 name: String,
315}
316
317#[derive(Debug, Serialize)]
318struct AnthropicMessage {
319 role: String,
320 content: Vec<AnthropicContent>,
321}
322
323#[derive(Debug, Clone, Serialize)]
324struct AnthropicContent {
325 #[serde(rename = "type")]
326 content_type: String,
327 text: String,
328 #[serde(skip_serializing_if = "Option::is_none")]
329 cache_control: Option<PromptCacheControl>,
330}
331
332#[derive(Debug, Deserialize)]
333struct ToolCall {
334 function: FunctionCall,
335}
336
337#[derive(Debug, Deserialize)]
338struct FunctionCall {
339 name: String,
340 arguments: String,
341}
342
343#[derive(Debug, Deserialize)]
344struct Choice {
345 message: ResponseMessage,
346}
347
348#[derive(Debug, Deserialize)]
349struct ResponseMessage {
350 #[serde(default)]
351 tool_calls: Vec<ToolCall>,
352 #[serde(default)]
353 content: Option<String>,
354}
355
356#[derive(Debug, Deserialize)]
357struct ApiResponse {
358 choices: Vec<Choice>,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
362struct SummaryOutput {
363 summary: String,
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367struct FastCommitOutput {
368 #[serde(rename = "type")]
369 commit_type: String,
370 scope: Option<String>,
371 summary: String,
372 #[serde(default)]
373 details: Vec<String>,
374}
375
376pub async fn retry_api_call<T>(
378 config: &CommitConfig,
379 mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
380) -> Result<T> {
381 let mut attempt = 0;
382
383 loop {
384 attempt += 1;
385
386 match f().await {
387 Ok((false, Some(result))) => return Ok(result),
388 Ok((false, None)) => {
389 return Err(CommitGenError::Other("API call failed without result".to_string()));
390 },
391 Ok((true, _)) if attempt < config.max_retries => {
392 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
393 eprintln!(
394 "{}",
395 crate::style::warning(&format!(
396 "Retry {}/{} after {}ms...",
397 attempt, config.max_retries, backoff_ms
398 ))
399 );
400 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
401 },
402 Ok((true, _last_err)) => {
403 return Err(CommitGenError::ApiRetryExhausted {
404 retries: config.max_retries,
405 source: Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
406 });
407 },
408 Err(e) => {
409 if attempt < config.max_retries {
410 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
411 eprintln!(
412 "{}",
413 crate::style::warning(&format!(
414 "Error: {} - Retry {}/{} after {}ms...",
415 e, attempt, config.max_retries, backoff_ms
416 ))
417 );
418 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
419 continue;
420 }
421 return Err(e);
422 },
423 }
424 }
425}
426
427pub fn format_types_description(config: &CommitConfig) -> String {
430 use std::fmt::Write;
431 let mut out = String::from("Check types in order (first match wins):\n\n");
432
433 for (name, tc) in &config.types {
434 let _ = writeln!(out, "**{name}**: {}", tc.description);
435 if !tc.diff_indicators.is_empty() {
436 let _ = writeln!(out, " Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
437 }
438 if !tc.file_patterns.is_empty() {
439 let _ = writeln!(out, " File patterns: {}", tc.file_patterns.join(", "));
440 }
441 for ex in &tc.examples {
442 let _ = writeln!(out, " - {ex}");
443 }
444 if !tc.hint.is_empty() {
445 let _ = writeln!(out, " Note: {}", tc.hint);
446 }
447 out.push('\n');
448 }
449
450 if !config.classifier_hint.is_empty() {
451 let _ = writeln!(out, "\n{}", config.classifier_hint);
452 }
453
454 out
455}
456
457pub async fn generate_conventional_analysis<'a>(
459 stat: &'a str,
460 diff: &'a str,
461 model_name: &'a str,
462 scope_candidates_str: &'a str,
463 ctx: &AnalysisContext<'a>,
464 config: &'a CommitConfig,
465) -> Result<ConventionalAnalysis> {
466 retry_api_call(config, async move || {
467 let client = get_client(config);
468
469 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
471
472 let tool = Tool {
474 tool_type: "function".to_string(),
475 function: Function {
476 name: "create_conventional_analysis".to_string(),
477 description: "Analyze changes and classify as conventional commit with type, scope, \
478 details, and metadata"
479 .to_string(),
480 parameters: FunctionParameters {
481 param_type: "object".to_string(),
482 properties: serde_json::json!({
483 "type": {
484 "type": "string",
485 "enum": type_enum,
486 "description": "Commit type based on change classification"
487 },
488 "scope": {
489 "type": "string",
490 "description": "Optional scope (module/component). Omit if unclear or multi-component."
491 },
492 "details": {
493 "type": "array",
494 "description": "Array of 0-6 detail items with changelog metadata.",
495 "items": {
496 "type": "object",
497 "properties": {
498 "text": {
499 "type": "string",
500 "description": "Detail about change, starting with past-tense verb, ending with period"
501 },
502 "changelog_category": {
503 "type": "string",
504 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
505 "description": "Changelog category if user-visible. Omit for internal changes."
506 },
507 "user_visible": {
508 "type": "boolean",
509 "description": "True if this change affects users/API and should appear in changelog"
510 }
511 },
512 "required": ["text", "user_visible"]
513 }
514 },
515 "issue_refs": {
516 "type": "array",
517 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
518 "items": {
519 "type": "string"
520 }
521 }
522 }),
523 required: vec![
524 "type".to_string(),
525 "details".to_string(),
526 "issue_refs".to_string(),
527 ],
528 },
529 },
530 };
531
532 let debug_dir = ctx.debug_output;
533 let debug_prefix = ctx.debug_prefix;
534 let mode = config.resolved_api_mode(model_name);
535
536 let response_text = match mode {
537 ResolvedApiMode::ChatCompletions => {
538 let types_desc = format_types_description(config);
539 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
540 variant: &config.analysis_prompt_variant,
541 stat,
542 diff,
543 scope_candidates: scope_candidates_str,
544 recent_commits: ctx.recent_commits,
545 common_scopes: ctx.common_scopes,
546 types_description: Some(&types_desc),
547 project_context: ctx.project_context,
548 })?;
549
550 let user_content = if let Some(user_ctx) = ctx.user_context {
551 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
552 } else {
553 parts.user
554 };
555
556 let prompt_cache_key = openai_prompt_cache_key(
557 config,
558 model_name,
559 "analysis",
560 &config.analysis_prompt_variant,
561 &parts.system,
562 );
563 let request = ApiRequest {
564 model: model_name.to_string(),
565 max_tokens: 1000,
566 temperature: config.temperature,
567 tools: vec![tool],
568 tool_choice: Some(
569 serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
570 ),
571 prompt_cache_key,
572 messages: vec![
573 Message { role: "system".to_string(), content: parts.system },
574 Message { role: "user".to_string(), content: user_content },
575 ],
576 };
577
578 if debug_dir.is_some() {
579 let request_json = serde_json::to_string_pretty(&request)?;
580 save_debug_output(
581 debug_dir,
582 &debug_filename(debug_prefix, "analysis_request.json"),
583 &request_json,
584 )?;
585 }
586
587 let mut request_builder = client
588 .post(format!("{}/chat/completions", config.api_base_url))
589 .header("content-type", "application/json");
590
591 if let Some(api_key) = &config.api_key {
593 request_builder =
594 request_builder.header("Authorization", format!("Bearer {api_key}"));
595 }
596
597 let (status, response_text) =
598 timed_send(request_builder.json(&request), "analysis", model_name).await?;
599 if debug_dir.is_some() {
600 save_debug_output(
601 debug_dir,
602 &debug_filename(debug_prefix, "analysis_response.json"),
603 &response_text,
604 )?;
605 }
606
607 if status.is_server_error() {
609 eprintln!(
610 "{}",
611 crate::style::error(&format!("Server error {status}: {response_text}"))
612 );
613 return Ok((true, None)); }
615
616 if !status.is_success() {
617 return Err(CommitGenError::ApiError {
618 status: status.as_u16(),
619 body: response_text,
620 });
621 }
622
623 response_text
624 },
625 ResolvedApiMode::AnthropicMessages => {
626 let types_desc = format_types_description(config);
627 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
628 variant: &config.analysis_prompt_variant,
629 stat,
630 diff,
631 scope_candidates: scope_candidates_str,
632 recent_commits: ctx.recent_commits,
633 common_scopes: ctx.common_scopes,
634 types_description: Some(&types_desc),
635 project_context: ctx.project_context,
636 })?;
637
638 let user_content = if let Some(user_ctx) = ctx.user_context {
639 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
640 } else {
641 parts.user
642 };
643
644 let prompt_caching = anthropic_prompt_caching_enabled(config);
645 let mut tools = vec![AnthropicTool {
646 name: "create_conventional_analysis".to_string(),
647 description: "Analyze changes and classify as conventional commit with type, \
648 scope, details, and metadata"
649 .to_string(),
650 input_schema: serde_json::json!({
651 "type": "object",
652 "properties": {
653 "type": {
654 "type": "string",
655 "enum": type_enum,
656 "description": "Commit type based on change classification"
657 },
658 "scope": {
659 "type": "string",
660 "description": "Optional scope (module/component). Omit if unclear or multi-component."
661 },
662 "details": {
663 "type": "array",
664 "description": "Array of 0-6 detail items with changelog metadata.",
665 "items": {
666 "type": "object",
667 "properties": {
668 "text": {
669 "type": "string",
670 "description": "Detail about change, starting with past-tense verb, ending with period"
671 },
672 "changelog_category": {
673 "type": "string",
674 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
675 "description": "Changelog category if user-visible. Omit for internal changes."
676 },
677 "user_visible": {
678 "type": "boolean",
679 "description": "True if this change affects users/API and should appear in changelog"
680 }
681 },
682 "required": ["text", "user_visible"]
683 }
684 },
685 "issue_refs": {
686 "type": "array",
687 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
688 "items": {
689 "type": "string"
690 }
691 }
692 },
693 "required": ["type", "details", "issue_refs"]
694 }),
695 cache_control: None,
696 }];
697 cache_last_anthropic_tool(&mut tools, prompt_caching);
698
699 let request = AnthropicRequest {
700 model: model_name.to_string(),
701 max_tokens: 1000,
702 temperature: config.temperature,
703 system: anthropic_system_content(&parts.system, prompt_caching),
704 tools,
705 tool_choice: Some(AnthropicToolChoice {
706 choice_type: "tool".to_string(),
707 name: "create_conventional_analysis".to_string(),
708 }),
709 messages: vec![AnthropicMessage {
710 role: "user".to_string(),
711 content: vec![anthropic_text_content(user_content, false)],
712 }],
713 };
714
715 if debug_dir.is_some() {
716 let request_json = serde_json::to_string_pretty(&request)?;
717 save_debug_output(
718 debug_dir,
719 &debug_filename(debug_prefix, "analysis_request.json"),
720 &request_json,
721 )?;
722 }
723
724 let mut request_builder = append_anthropic_cache_beta_header(
725 client
726 .post(anthropic_messages_url(&config.api_base_url))
727 .header("content-type", "application/json")
728 .header("anthropic-version", "2023-06-01"),
729 prompt_caching,
730 );
731
732 if let Some(api_key) = &config.api_key {
733 request_builder = request_builder.header("x-api-key", api_key);
734 }
735
736 let (status, response_text) =
737 timed_send(request_builder.json(&request), "analysis", model_name).await?;
738 if debug_dir.is_some() {
739 save_debug_output(
740 debug_dir,
741 &debug_filename(debug_prefix, "analysis_response.json"),
742 &response_text,
743 )?;
744 }
745
746 if status.is_server_error() {
747 eprintln!(
748 "{}",
749 crate::style::error(&format!("Server error {status}: {response_text}"))
750 );
751 return Ok((true, None));
752 }
753
754 if !status.is_success() {
755 return Err(CommitGenError::ApiError {
756 status: status.as_u16(),
757 body: response_text,
758 });
759 }
760
761 response_text
762 },
763 };
764
765 if response_text.trim().is_empty() {
766 crate::style::warn("Model returned empty response body for analysis; retrying.");
767 return Ok((true, None));
768 }
769
770 match mode {
771 ResolvedApiMode::ChatCompletions => {
772 let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
773 CommitGenError::Other(format!(
774 "Failed to parse analysis response JSON: {e}. Response body: {}",
775 response_snippet(&response_text, 500)
776 ))
777 })?;
778
779 if api_response.choices.is_empty() {
780 return Err(CommitGenError::Other(
781 "API returned empty response for change analysis".to_string(),
782 ));
783 }
784
785 let message = &api_response.choices[0].message;
786
787 if !message.tool_calls.is_empty() {
789 let tool_call = &message.tool_calls[0];
790 if tool_call
791 .function
792 .name
793 .ends_with("create_conventional_analysis")
794 {
795 let args = &tool_call.function.arguments;
796 if args.is_empty() {
797 crate::style::warn(
798 "Model returned empty function arguments. Model may not support function \
799 calling properly.",
800 );
801 return Err(CommitGenError::Other(
802 "Model returned empty function arguments - try using a Claude model \
803 (sonnet/opus/haiku)"
804 .to_string(),
805 ));
806 }
807 let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
808 CommitGenError::Other(format!(
809 "Failed to parse model response: {}. Response was: {}",
810 e,
811 args.chars().take(200).collect::<String>()
812 ))
813 })?;
814 return Ok((false, Some(analysis)));
815 }
816 }
817
818 if let Some(content) = &message.content {
820 if content.trim().is_empty() {
821 crate::style::warn("Model returned empty content for analysis; retrying.");
822 return Ok((true, None));
823 }
824 let analysis: ConventionalAnalysis =
825 serde_json::from_str(content.trim()).map_err(|e| {
826 CommitGenError::Other(format!(
827 "Failed to parse analysis content JSON: {e}. Content: {}",
828 response_snippet(content, 500)
829 ))
830 })?;
831 return Ok((false, Some(analysis)));
832 }
833
834 Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
835 },
836 ResolvedApiMode::AnthropicMessages => {
837 let (tool_input, text_content) =
838 extract_anthropic_content(&response_text, "create_conventional_analysis")?;
839
840 if let Some(input) = tool_input {
841 let analysis: ConventionalAnalysis = serde_json::from_value(input).map_err(|e| {
842 CommitGenError::Other(format!(
843 "Failed to parse analysis tool input: {e}. Response body: {}",
844 response_snippet(&response_text, 500)
845 ))
846 })?;
847 return Ok((false, Some(analysis)));
848 }
849
850 if text_content.trim().is_empty() {
851 crate::style::warn("Model returned empty content for analysis; retrying.");
852 return Ok((true, None));
853 }
854
855 let analysis: ConventionalAnalysis = serde_json::from_str(text_content.trim())
856 .map_err(|e| {
857 CommitGenError::Other(format!(
858 "Failed to parse analysis content JSON: {e}. Content: {}",
859 response_snippet(&text_content, 500)
860 ))
861 })?;
862 Ok((false, Some(analysis)))
863 },
864 }
865 }).await
866}
867
868fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
873 let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
874 let prefix = format!("{commit_type}{scope_part}: ");
875
876 summary
877 .strip_prefix(&prefix)
878 .or_else(|| {
879 let prefix_no_scope = format!("{commit_type}: ");
881 summary.strip_prefix(&prefix_no_scope)
882 })
883 .unwrap_or(summary)
884 .to_string()
885}
886
887fn validate_summary_quality(
889 summary: &str,
890 commit_type: &str,
891 stat: &str,
892) -> std::result::Result<(), String> {
893 use crate::validation::is_past_tense_verb;
894
895 let first_word = summary
896 .split_whitespace()
897 .next()
898 .ok_or_else(|| "summary is empty".to_string())?;
899
900 let first_word_lower = first_word.to_lowercase();
901
902 if !is_past_tense_verb(&first_word_lower) {
904 return Err(format!(
905 "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
906 ));
907 }
908
909 if first_word_lower == commit_type {
911 return Err(format!("repeats commit type '{commit_type}' in summary"));
912 }
913
914 let file_exts: Vec<&str> = stat
916 .lines()
917 .filter_map(|line| {
918 let path = line.split('|').next()?.trim();
919 std::path::Path::new(path).extension()?.to_str()
920 })
921 .collect();
922
923 if !file_exts.is_empty() {
924 let total = file_exts.len();
925 let md_count = file_exts.iter().filter(|&&e| e == "md").count();
926
927 if md_count * 100 / total > 80 && commit_type != "docs" {
929 crate::style::warn(&format!(
930 "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
931 md_count * 100 / total,
932 commit_type
933 ));
934 }
935
936 let code_exts = [
938 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
940 "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",
953 "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",
972 ];
973 let code_count = file_exts
974 .iter()
975 .filter(|&&e| code_exts.contains(&e))
976 .count();
977 if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
978 crate::style::warn(&format!(
979 "Type mismatch: no code files changed but type is '{commit_type}'"
980 ));
981 }
982 }
983
984 Ok(())
985}
986
987#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
989pub async fn generate_summary_from_analysis<'a>(
990 stat: &'a str,
991 commit_type: &'a str,
992 scope: Option<&'a str>,
993 details: &'a [String],
994 user_context: Option<&'a str>,
995 config: &'a CommitConfig,
996 debug_dir: Option<&'a Path>,
997 debug_prefix: Option<&'a str>,
998) -> Result<CommitSummary> {
999 let mut validation_attempt = 0;
1000 let max_validation_retries = 1;
1001 let mut last_failure_reason: Option<String> = None;
1002
1003 loop {
1004 let additional_constraint = if let Some(reason) = &last_failure_reason {
1005 format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1006 } else {
1007 String::new()
1008 };
1009
1010 let result = retry_api_call(config, async move || {
1011 let bullet_points = details.join("\n");
1013
1014 let client = get_client(config);
1015
1016 let tool = Tool {
1017 tool_type: "function".to_string(),
1018 function: Function {
1019 name: "create_commit_summary".to_string(),
1020 description: "Compose a git commit summary line from detail statements".to_string(),
1021 parameters: FunctionParameters {
1022 param_type: "object".to_string(),
1023 properties: serde_json::json!({
1024 "summary": {
1025 "type": "string",
1026 "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1027 "maxLength": config.summary_hard_limit
1028 }
1029 }),
1030 required: vec!["summary".to_string()],
1031 },
1032 },
1033 };
1034
1035 let scope_str = scope.unwrap_or("");
1037 let prefix_len =
1038 commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 }; let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
1040
1041 let mode = config.resolved_api_mode(&config.model);
1042
1043 let response_text = match mode {
1044 ResolvedApiMode::ChatCompletions => {
1045 let details_str = if bullet_points.is_empty() {
1046 "None (no supporting detail points were generated)."
1047 } else {
1048 bullet_points.as_str()
1049 };
1050
1051 let parts = templates::render_summary_prompt(
1052 &config.summary_prompt_variant,
1053 commit_type,
1054 scope_str,
1055 &max_summary_len.to_string(),
1056 details_str,
1057 stat.trim(),
1058 user_context,
1059 )?;
1060
1061 let user_content = format!("{}{additional_constraint}", parts.user);
1062
1063 let prompt_cache_key = openai_prompt_cache_key(
1064 config,
1065 &config.model,
1066 "summary",
1067 &config.summary_prompt_variant,
1068 &parts.system,
1069 );
1070 let request = ApiRequest {
1071 model: config.model.clone(),
1072 max_tokens: 200,
1073 temperature: config.temperature,
1074 tools: vec![tool],
1075 tool_choice: Some(serde_json::json!({
1076 "type": "function",
1077 "function": { "name": "create_commit_summary" }
1078 })),
1079 prompt_cache_key,
1080 messages: vec![
1081 Message { role: "system".to_string(), content: parts.system },
1082 Message { role: "user".to_string(), content: user_content },
1083 ],
1084 };
1085
1086 if debug_dir.is_some() {
1087 let request_json = serde_json::to_string_pretty(&request)?;
1088 save_debug_output(
1089 debug_dir,
1090 &debug_filename(debug_prefix, "summary_request.json"),
1091 &request_json,
1092 )?;
1093 }
1094
1095 let mut request_builder = client
1096 .post(format!("{}/chat/completions", config.api_base_url))
1097 .header("content-type", "application/json");
1098
1099 if let Some(api_key) = &config.api_key {
1101 request_builder =
1102 request_builder.header("Authorization", format!("Bearer {api_key}"));
1103 }
1104
1105 let (status, response_text) =
1106 timed_send(request_builder.json(&request), "summary", &config.model).await?;
1107 if debug_dir.is_some() {
1108 save_debug_output(
1109 debug_dir,
1110 &debug_filename(debug_prefix, "summary_response.json"),
1111 &response_text,
1112 )?;
1113 }
1114
1115 if status.is_server_error() {
1117 eprintln!(
1118 "{}",
1119 crate::style::error(&format!("Server error {status}: {response_text}"))
1120 );
1121 return Ok((true, None)); }
1123
1124 if !status.is_success() {
1125 return Err(CommitGenError::ApiError {
1126 status: status.as_u16(),
1127 body: response_text,
1128 });
1129 }
1130
1131 response_text
1132 },
1133 ResolvedApiMode::AnthropicMessages => {
1134 let details_str = if bullet_points.is_empty() {
1135 "None (no supporting detail points were generated)."
1136 } else {
1137 bullet_points.as_str()
1138 };
1139
1140 let parts = templates::render_summary_prompt(
1141 &config.summary_prompt_variant,
1142 commit_type,
1143 scope_str,
1144 &max_summary_len.to_string(),
1145 details_str,
1146 stat.trim(),
1147 user_context,
1148 )?;
1149
1150 let user_content = format!("{}{additional_constraint}", parts.user);
1151
1152 let prompt_caching = anthropic_prompt_caching_enabled(config);
1153 let mut tools = vec![AnthropicTool {
1154 name: "create_commit_summary".to_string(),
1155 description: "Compose a git commit summary line from detail statements"
1156 .to_string(),
1157 input_schema: serde_json::json!({
1158 "type": "object",
1159 "properties": {
1160 "summary": {
1161 "type": "string",
1162 "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1163 "maxLength": config.summary_hard_limit
1164 }
1165 },
1166 "required": ["summary"]
1167 }),
1168 cache_control: None,
1169 }];
1170 cache_last_anthropic_tool(&mut tools, prompt_caching);
1171
1172 let request = AnthropicRequest {
1173 model: config.model.clone(),
1174 max_tokens: 200,
1175 temperature: config.temperature,
1176 system: anthropic_system_content(&parts.system, prompt_caching),
1177 tools,
1178 tool_choice: Some(AnthropicToolChoice {
1179 choice_type: "tool".to_string(),
1180 name: "create_commit_summary".to_string(),
1181 }),
1182 messages: vec![AnthropicMessage {
1183 role: "user".to_string(),
1184 content: vec![anthropic_text_content(user_content, false)],
1185 }],
1186 };
1187
1188 if debug_dir.is_some() {
1189 let request_json = serde_json::to_string_pretty(&request)?;
1190 save_debug_output(
1191 debug_dir,
1192 &debug_filename(debug_prefix, "summary_request.json"),
1193 &request_json,
1194 )?;
1195 }
1196
1197 let mut request_builder = append_anthropic_cache_beta_header(
1198 client
1199 .post(anthropic_messages_url(&config.api_base_url))
1200 .header("content-type", "application/json")
1201 .header("anthropic-version", "2023-06-01"),
1202 prompt_caching,
1203 );
1204
1205 if let Some(api_key) = &config.api_key {
1206 request_builder = request_builder.header("x-api-key", api_key);
1207 }
1208
1209 let (status, response_text) =
1210 timed_send(request_builder.json(&request), "summary", &config.model).await?;
1211 if debug_dir.is_some() {
1212 save_debug_output(
1213 debug_dir,
1214 &debug_filename(debug_prefix, "summary_response.json"),
1215 &response_text,
1216 )?;
1217 }
1218
1219 if status.is_server_error() {
1221 eprintln!(
1222 "{}",
1223 crate::style::error(&format!("Server error {status}: {response_text}"))
1224 );
1225 return Ok((true, None)); }
1227
1228 if !status.is_success() {
1229 return Err(CommitGenError::ApiError {
1230 status: status.as_u16(),
1231 body: response_text,
1232 });
1233 }
1234
1235 response_text
1236 },
1237 };
1238
1239 if response_text.trim().is_empty() {
1240 crate::style::warn("Model returned empty response body for summary; retrying.");
1241 return Ok((true, None));
1242 }
1243
1244 match mode {
1245 ResolvedApiMode::ChatCompletions => {
1246 let api_response: ApiResponse =
1247 serde_json::from_str(&response_text).map_err(|e| {
1248 CommitGenError::Other(format!(
1249 "Failed to parse summary response JSON: {e}. Response body: {}",
1250 response_snippet(&response_text, 500)
1251 ))
1252 })?;
1253
1254 if api_response.choices.is_empty() {
1255 return Err(CommitGenError::Other(
1256 "Summary creation response was empty".to_string(),
1257 ));
1258 }
1259
1260 let message_choice = &api_response.choices[0].message;
1261
1262 if !message_choice.tool_calls.is_empty() {
1263 let tool_call = &message_choice.tool_calls[0];
1264 if tool_call.function.name.ends_with("create_commit_summary") {
1265 let args = &tool_call.function.arguments;
1266 if args.is_empty() {
1267 crate::style::warn(
1268 "Model returned empty function arguments for summary. Model may not \
1269 support function calling.",
1270 );
1271 return Err(CommitGenError::Other(
1272 "Model returned empty summary arguments - try using a Claude model \
1273 (sonnet/opus/haiku)"
1274 .to_string(),
1275 ));
1276 }
1277 let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
1278 CommitGenError::Other(format!(
1279 "Failed to parse summary response: {}. Response was: {}",
1280 e,
1281 args.chars().take(200).collect::<String>()
1282 ))
1283 })?;
1284 let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1287 return Ok((
1288 false,
1289 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1290 ));
1291 }
1292 }
1293
1294 if let Some(content) = &message_choice.content {
1295 if content.trim().is_empty() {
1296 crate::style::warn("Model returned empty content for summary; retrying.");
1297 return Ok((true, None));
1298 }
1299 let trimmed = content.trim();
1301 let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1302 Ok(summary) => summary.summary,
1303 Err(e) => {
1304 if trimmed.starts_with('{') {
1306 return Err(CommitGenError::Other(format!(
1307 "Failed to parse summary JSON: {e}. Content: {}",
1308 response_snippet(trimmed, 500)
1309 )));
1310 }
1311 trimmed.to_string()
1313 },
1314 };
1315 let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1317 return Ok((
1318 false,
1319 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1320 ));
1321 }
1322
1323 Err(CommitGenError::Other(
1324 "No summary found in summary creation response".to_string(),
1325 ))
1326 },
1327 ResolvedApiMode::AnthropicMessages => {
1328 let (tool_input, text_content) =
1329 extract_anthropic_content(&response_text, "create_commit_summary")?;
1330
1331 if let Some(input) = tool_input {
1332 let summary: SummaryOutput = serde_json::from_value(input).map_err(|e| {
1333 CommitGenError::Other(format!(
1334 "Failed to parse summary tool input: {e}. Response body: {}",
1335 response_snippet(&response_text, 500)
1336 ))
1337 })?;
1338 let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1339 return Ok((
1340 false,
1341 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1342 ));
1343 }
1344
1345 if text_content.trim().is_empty() {
1346 crate::style::warn("Model returned empty content for summary; retrying.");
1347 return Ok((true, None));
1348 }
1349
1350 let trimmed = text_content.trim();
1352 let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1353 Ok(summary) => summary.summary,
1354 Err(e) => {
1355 if trimmed.starts_with('{') {
1357 return Err(CommitGenError::Other(format!(
1358 "Failed to parse summary JSON: {e}. Content: {}",
1359 response_snippet(trimmed, 500)
1360 )));
1361 }
1362 trimmed.to_string()
1364 },
1365 };
1366 let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1367 Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)))
1368 },
1369 }
1370 }).await;
1371
1372 match result {
1373 Ok(summary) => {
1374 match validate_summary_quality(summary.as_str(), commit_type, stat) {
1376 Ok(()) => return Ok(summary),
1377 Err(reason) if validation_attempt < max_validation_retries => {
1378 crate::style::warn(&format!(
1379 "Validation failed (attempt {}/{}): {}",
1380 validation_attempt + 1,
1381 max_validation_retries + 1,
1382 reason
1383 ));
1384 last_failure_reason = Some(reason);
1385 validation_attempt += 1;
1386 },
1388 Err(reason) => {
1389 crate::style::warn(&format!(
1390 "Validation failed after {} retries: {}. Using fallback.",
1391 max_validation_retries + 1,
1392 reason
1393 ));
1394 return Ok(fallback_from_details_or_summary(
1396 details,
1397 summary.as_str(),
1398 commit_type,
1399 config,
1400 ));
1401 },
1402 }
1403 },
1404 Err(e) => return Err(e),
1405 }
1406 }
1407}
1408
1409fn fallback_from_details_or_summary(
1411 details: &[String],
1412 invalid_summary: &str,
1413 commit_type: &str,
1414 config: &CommitConfig,
1415) -> CommitSummary {
1416 let candidate = if let Some(first_detail) = details.first() {
1417 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1419
1420 let type_word_variants =
1422 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1423 for variant in &type_word_variants {
1424 if cleaned
1425 .to_lowercase()
1426 .starts_with(&format!("{} ", variant.to_lowercase()))
1427 {
1428 cleaned = cleaned[variant.len()..].trim().to_string();
1429 break;
1430 }
1431 }
1432
1433 cleaned
1434 } else {
1435 let mut cleaned = invalid_summary
1437 .split_whitespace()
1438 .skip(1) .collect::<Vec<_>>()
1440 .join(" ");
1441
1442 if cleaned.is_empty() {
1443 cleaned = fallback_summary("", details, commit_type, config)
1444 .as_str()
1445 .to_string();
1446 }
1447
1448 cleaned
1449 };
1450
1451 let with_verb = if candidate
1453 .split_whitespace()
1454 .next()
1455 .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
1456 {
1457 candidate
1458 } else {
1459 let verb = match commit_type {
1460 "feat" => "added",
1461 "fix" => "fixed",
1462 "refactor" => "restructured",
1463 "docs" => "documented",
1464 "test" => "tested",
1465 "perf" => "optimized",
1466 "build" | "ci" | "chore" => "updated",
1467 "style" => "formatted",
1468 "revert" => "reverted",
1469 _ => "changed",
1470 };
1471 format!("{verb} {candidate}")
1472 };
1473
1474 CommitSummary::new(with_verb, config.summary_hard_limit)
1475 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1476}
1477
1478pub fn fallback_summary(
1480 stat: &str,
1481 details: &[String],
1482 commit_type: &str,
1483 config: &CommitConfig,
1484) -> CommitSummary {
1485 let mut candidate = if let Some(first) = details.first() {
1486 first.trim().trim_end_matches('.').to_string()
1487 } else {
1488 let primary_line = stat
1489 .lines()
1490 .map(str::trim)
1491 .find(|line| !line.is_empty())
1492 .unwrap_or("files");
1493
1494 let subject = primary_line
1495 .split('|')
1496 .next()
1497 .map(str::trim)
1498 .filter(|s| !s.is_empty())
1499 .unwrap_or("files");
1500
1501 if subject.eq_ignore_ascii_case("files") {
1502 "Updated files".to_string()
1503 } else {
1504 format!("Updated {subject}")
1505 }
1506 };
1507
1508 candidate = candidate
1509 .replace(['\n', '\r'], " ")
1510 .split_whitespace()
1511 .collect::<Vec<_>>()
1512 .join(" ")
1513 .trim()
1514 .trim_end_matches('.')
1515 .trim_end_matches(';')
1516 .trim_end_matches(':')
1517 .to_string();
1518
1519 if candidate.is_empty() {
1520 candidate = "Updated files".to_string();
1521 }
1522
1523 const CONSERVATIVE_MAX: usize = 50;
1526 while candidate.len() > CONSERVATIVE_MAX {
1527 if let Some(pos) = candidate.rfind(' ') {
1528 candidate.truncate(pos);
1529 candidate = candidate.trim_end_matches(',').trim().to_string();
1530 } else {
1531 candidate.truncate(CONSERVATIVE_MAX);
1532 break;
1533 }
1534 }
1535
1536 candidate = candidate.trim_end_matches('.').to_string();
1538
1539 if candidate
1542 .split_whitespace()
1543 .next()
1544 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1545 {
1546 candidate = match commit_type {
1547 "refactor" => "restructured change".to_string(),
1548 "feat" => "added functionality".to_string(),
1549 "fix" => "fixed issue".to_string(),
1550 "docs" => "documented updates".to_string(),
1551 "test" => "tested changes".to_string(),
1552 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1553 "perf" => "optimized performance".to_string(),
1554 "revert" => "reverted previous commit".to_string(),
1555 _ => "updated files".to_string(),
1556 };
1557 }
1558
1559 CommitSummary::new(candidate, config.summary_hard_limit)
1562 .expect("fallback summary should always be valid")
1563}
1564
1565pub async fn generate_analysis_with_map_reduce<'a>(
1570 stat: &'a str,
1571 diff: &'a str,
1572 model_name: &'a str,
1573 scope_candidates_str: &'a str,
1574 ctx: &AnalysisContext<'a>,
1575 config: &'a CommitConfig,
1576 counter: &TokenCounter,
1577) -> Result<ConventionalAnalysis> {
1578 use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1579
1580 if should_use_map_reduce(diff, config, counter) {
1581 crate::style::print_info(&format!(
1582 "Large diff detected ({} tokens), using map-reduce...",
1583 counter.count_sync(diff)
1584 ));
1585 run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1586 } else {
1587 generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1588 .await
1589 }
1590}
1591
1592pub async fn generate_fast_commit(
1596 stat: &str,
1597 diff: &str,
1598 model_name: &str,
1599 scope_candidates_str: &str,
1600 user_context: Option<&str>,
1601 config: &CommitConfig,
1602 debug_dir: Option<&Path>,
1603) -> Result<ConventionalCommit> {
1604 retry_api_call(config, async move || {
1605 let client = get_client(config);
1606
1607 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1609
1610 let parts = templates::render_fast_prompt(&templates::FastPromptParams {
1611 variant: "default",
1612 stat,
1613 diff,
1614 scope_candidates: scope_candidates_str,
1615 user_context,
1616 })?;
1617
1618 let mode = config.resolved_api_mode(model_name);
1619
1620 let response_text = match mode {
1621 ResolvedApiMode::ChatCompletions => {
1622 let tool = Tool {
1623 tool_type: "function".to_string(),
1624 function: Function {
1625 name: "create_fast_commit".to_string(),
1626 description: "Generate a conventional commit from the given diff".to_string(),
1627 parameters: FunctionParameters {
1628 param_type: "object".to_string(),
1629 properties: serde_json::json!({
1630 "type": {
1631 "type": "string",
1632 "enum": type_enum,
1633 "description": "Conventional commit type"
1634 },
1635 "scope": {
1636 "type": "string",
1637 "description": "Optional scope. Omit if unclear or cross-cutting."
1638 },
1639 "summary": {
1640 "type": "string",
1641 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
1642 },
1643 "details": {
1644 "type": "array",
1645 "items": { "type": "string" },
1646 "description": "0-3 past-tense detail sentences ending with period"
1647 }
1648 }),
1649 required: vec![
1650 "type".to_string(),
1651 "summary".to_string(),
1652 "details".to_string(),
1653 ],
1654 },
1655 },
1656 };
1657
1658 let prompt_cache_key = openai_prompt_cache_key(
1659 config,
1660 model_name,
1661 "fast",
1662 "default",
1663 &parts.system,
1664 );
1665 let request = ApiRequest {
1666 model: model_name.to_string(),
1667 max_tokens: 500,
1668 temperature: config.temperature,
1669 tools: vec![tool],
1670 tool_choice: Some(
1671 serde_json::json!({ "type": "function", "function": { "name": "create_fast_commit" } }),
1672 ),
1673 prompt_cache_key,
1674 messages: vec![
1675 Message { role: "system".to_string(), content: parts.system },
1676 Message { role: "user".to_string(), content: parts.user },
1677 ],
1678 };
1679
1680 if debug_dir.is_some() {
1681 let request_json = serde_json::to_string_pretty(&request)?;
1682 save_debug_output(debug_dir, &debug_filename(None, "fast_request.json"), &request_json)?;
1683 }
1684
1685 let mut request_builder = client
1686 .post(format!("{}/chat/completions", config.api_base_url))
1687 .header("content-type", "application/json");
1688
1689 if let Some(api_key) = &config.api_key {
1690 request_builder =
1691 request_builder.header("Authorization", format!("Bearer {api_key}"));
1692 }
1693
1694 let (status, response_text) =
1695 timed_send(request_builder.json(&request), "fast", model_name).await?;
1696 if debug_dir.is_some() {
1697 save_debug_output(
1698 debug_dir,
1699 &debug_filename(None, "fast_response.json"),
1700 &response_text,
1701 )?;
1702 }
1703
1704 if status.is_server_error() {
1705 eprintln!(
1706 "{}",
1707 crate::style::error(&format!("Server error {status}: {response_text}"))
1708 );
1709 return Ok((true, None));
1710 }
1711
1712 if !status.is_success() {
1713 return Err(CommitGenError::ApiError {
1714 status: status.as_u16(),
1715 body: response_text,
1716 });
1717 }
1718
1719 response_text
1720 },
1721 ResolvedApiMode::AnthropicMessages => {
1722 let prompt_caching = anthropic_prompt_caching_enabled(config);
1723 let mut tools = vec![AnthropicTool {
1724 name: "create_fast_commit".to_string(),
1725 description: "Generate a conventional commit from the given diff".to_string(),
1726 input_schema: serde_json::json!({
1727 "type": "object",
1728 "properties": {
1729 "type": {
1730 "type": "string",
1731 "enum": type_enum,
1732 "description": "Conventional commit type"
1733 },
1734 "scope": {
1735 "type": "string",
1736 "description": "Optional scope. Omit if unclear or cross-cutting."
1737 },
1738 "summary": {
1739 "type": "string",
1740 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
1741 },
1742 "details": {
1743 "type": "array",
1744 "items": { "type": "string" },
1745 "description": "0-3 past-tense detail sentences ending with period"
1746 }
1747 },
1748 "required": ["type", "summary", "details"]
1749 }),
1750 cache_control: None,
1751 }];
1752 cache_last_anthropic_tool(&mut tools, prompt_caching);
1753
1754 let request = AnthropicRequest {
1755 model: model_name.to_string(),
1756 max_tokens: 500,
1757 temperature: config.temperature,
1758 system: anthropic_system_content(&parts.system, prompt_caching),
1759 tools,
1760 tool_choice: Some(AnthropicToolChoice {
1761 choice_type: "tool".to_string(),
1762 name: "create_fast_commit".to_string(),
1763 }),
1764 messages: vec![AnthropicMessage {
1765 role: "user".to_string(),
1766 content: vec![anthropic_text_content(parts.user, false)],
1767 }],
1768 };
1769
1770 if debug_dir.is_some() {
1771 let request_json = serde_json::to_string_pretty(&request)?;
1772 save_debug_output(debug_dir, &debug_filename(None, "fast_request.json"), &request_json)?;
1773 }
1774
1775 let mut request_builder = append_anthropic_cache_beta_header(
1776 client
1777 .post(anthropic_messages_url(&config.api_base_url))
1778 .header("content-type", "application/json")
1779 .header("anthropic-version", "2023-06-01"),
1780 prompt_caching,
1781 );
1782
1783 if let Some(api_key) = &config.api_key {
1784 request_builder = request_builder.header("x-api-key", api_key);
1785 }
1786
1787 let (status, response_text) =
1788 timed_send(request_builder.json(&request), "fast", model_name).await?;
1789 if debug_dir.is_some() {
1790 save_debug_output(
1791 debug_dir,
1792 &debug_filename(None, "fast_response.json"),
1793 &response_text,
1794 )?;
1795 }
1796
1797 if status.is_server_error() {
1798 eprintln!(
1799 "{}",
1800 crate::style::error(&format!("Server error {status}: {response_text}"))
1801 );
1802 return Ok((true, None));
1803 }
1804
1805 if !status.is_success() {
1806 return Err(CommitGenError::ApiError {
1807 status: status.as_u16(),
1808 body: response_text,
1809 });
1810 }
1811
1812 response_text
1813 },
1814 };
1815
1816 if response_text.trim().is_empty() {
1817 crate::style::warn("Model returned empty response body for fast commit; retrying.");
1818 return Ok((true, None));
1819 }
1820
1821 match mode {
1822 ResolvedApiMode::ChatCompletions => {
1823 let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
1824 CommitGenError::Other(format!(
1825 "Failed to parse fast commit response JSON: {e}. Response body: {}",
1826 response_snippet(&response_text, 500)
1827 ))
1828 })?;
1829
1830 if api_response.choices.is_empty() {
1831 return Err(CommitGenError::Other(
1832 "API returned empty response for fast commit".to_string(),
1833 ));
1834 }
1835
1836 let message = &api_response.choices[0].message;
1837
1838 if !message.tool_calls.is_empty() {
1839 let tool_call = &message.tool_calls[0];
1840 if tool_call.function.name.ends_with("create_fast_commit") {
1841 let args = &tool_call.function.arguments;
1842 if args.is_empty() {
1843 crate::style::warn(
1844 "Model returned empty function arguments. Model may not support function \
1845 calling properly.",
1846 );
1847 return Err(CommitGenError::Other(
1848 "Model returned empty function arguments - try using a Claude model \
1849 (sonnet/opus/haiku)"
1850 .to_string(),
1851 ));
1852 }
1853 let output: FastCommitOutput = serde_json::from_str(args).map_err(|e| {
1854 CommitGenError::Other(format!(
1855 "Failed to parse fast commit response: {}. Response was: {}",
1856 e,
1857 args.chars().take(200).collect::<String>()
1858 ))
1859 })?;
1860 let commit = build_fast_commit(output, config)?;
1861 return Ok((false, Some(commit)));
1862 }
1863 }
1864
1865 if let Some(content) = &message.content {
1866 if content.trim().is_empty() {
1867 crate::style::warn("Model returned empty content for fast commit; retrying.");
1868 return Ok((true, None));
1869 }
1870 let output: FastCommitOutput =
1871 serde_json::from_str(content.trim()).map_err(|e| {
1872 CommitGenError::Other(format!(
1873 "Failed to parse fast commit content JSON: {e}. Content: {}",
1874 response_snippet(content, 500)
1875 ))
1876 })?;
1877 let commit = build_fast_commit(output, config)?;
1878 return Ok((false, Some(commit)));
1879 }
1880
1881 Err(CommitGenError::Other("No fast commit found in API response".to_string()))
1882 },
1883 ResolvedApiMode::AnthropicMessages => {
1884 let (tool_input, text_content) =
1885 extract_anthropic_content(&response_text, "create_fast_commit")?;
1886
1887 if let Some(input) = tool_input {
1888 let output: FastCommitOutput = serde_json::from_value(input).map_err(|e| {
1889 CommitGenError::Other(format!(
1890 "Failed to parse fast commit tool input: {e}. Response body: {}",
1891 response_snippet(&response_text, 500)
1892 ))
1893 })?;
1894 let commit = build_fast_commit(output, config)?;
1895 return Ok((false, Some(commit)));
1896 }
1897
1898 if text_content.trim().is_empty() {
1899 crate::style::warn("Model returned empty content for fast commit; retrying.");
1900 return Ok((true, None));
1901 }
1902
1903 let output: FastCommitOutput = serde_json::from_str(text_content.trim())
1904 .map_err(|e| {
1905 CommitGenError::Other(format!(
1906 "Failed to parse fast commit content JSON: {e}. Content: {}",
1907 response_snippet(&text_content, 500)
1908 ))
1909 })?;
1910 let commit = build_fast_commit(output, config)?;
1911 Ok((false, Some(commit)))
1912 },
1913 }
1914 })
1915 .await
1916}
1917
1918fn build_fast_commit(
1920 output: FastCommitOutput,
1921 config: &CommitConfig,
1922) -> Result<ConventionalCommit> {
1923 let commit_type = CommitType::new(&output.commit_type)?;
1924 let scope = output.scope.as_deref().map(Scope::new).transpose()?;
1925 let summary = CommitSummary::new(&output.summary, config.summary_hard_limit)?;
1926 Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
1927}
1928#[cfg(test)]
1929mod tests {
1930 use super::*;
1931 use crate::config::CommitConfig;
1932
1933 #[test]
1934 fn test_validate_summary_quality_valid() {
1935 let stat = "src/main.rs | 10 +++++++---\n";
1936 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
1937 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
1938 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
1939 }
1940
1941 #[test]
1942 fn test_validate_summary_quality_invalid_verb() {
1943 let stat = "src/main.rs | 10 +++++++---\n";
1944 let result = validate_summary_quality("adding new feature", "feat", stat);
1945 assert!(result.is_err());
1946 assert!(result.unwrap_err().contains("past-tense verb"));
1947 }
1948
1949 #[test]
1950 fn test_validate_summary_quality_type_repetition() {
1951 let stat = "src/main.rs | 10 +++++++---\n";
1952 let result = validate_summary_quality("feat new feature", "feat", stat);
1954 assert!(result.is_err());
1955 assert!(result.unwrap_err().contains("past-tense verb"));
1956
1957 let result = validate_summary_quality("fix bug", "fix", stat);
1959 assert!(result.is_err());
1960 assert!(result.unwrap_err().contains("past-tense verb"));
1962 }
1963
1964 #[test]
1965 fn test_validate_summary_quality_empty() {
1966 let stat = "src/main.rs | 10 +++++++---\n";
1967 let result = validate_summary_quality("", "feat", stat);
1968 assert!(result.is_err());
1969 assert!(result.unwrap_err().contains("empty"));
1970 }
1971
1972 #[test]
1973 fn test_validate_summary_quality_markdown_type_mismatch() {
1974 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
1975 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
1977 }
1978
1979 #[test]
1980 fn test_validate_summary_quality_no_code_files() {
1981 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
1982 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
1984 }
1985
1986 #[test]
1987 fn test_fallback_from_details_with_first_detail() {
1988 let config = CommitConfig::default();
1989 let details = vec![
1990 "Added authentication middleware.".to_string(),
1991 "Updated error handling.".to_string(),
1992 ];
1993 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
1994 assert_eq!(result.as_str(), "Added authentication middleware");
1996 }
1997
1998 #[test]
1999 fn test_fallback_from_details_strips_type_word() {
2000 let config = CommitConfig::default();
2001 let details = vec!["Featuring new oauth flow.".to_string()];
2002 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2003 assert!(result.as_str().starts_with("added"));
2006 }
2007
2008 #[test]
2009 fn test_fallback_from_details_no_details() {
2010 let config = CommitConfig::default();
2011 let details: Vec<String> = vec![];
2012 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2013 assert!(result.as_str().starts_with("added"));
2015 }
2016
2017 #[test]
2018 fn test_fallback_from_details_adds_verb() {
2019 let config = CommitConfig::default();
2020 let details = vec!["configuration for oauth".to_string()];
2021 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2022 assert_eq!(result.as_str(), "added configuration for oauth");
2023 }
2024
2025 #[test]
2026 fn test_fallback_from_details_preserves_existing_verb() {
2027 let config = CommitConfig::default();
2028 let details = vec!["fixed authentication bug".to_string()];
2029 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2030 assert_eq!(result.as_str(), "fixed authentication bug");
2031 }
2032
2033 #[test]
2034 fn test_fallback_from_details_type_specific_verbs() {
2035 let config = CommitConfig::default();
2036 let details = vec!["module structure".to_string()];
2037
2038 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2039 assert_eq!(result.as_str(), "restructured module structure");
2040
2041 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2042 assert_eq!(result.as_str(), "documented module structure");
2043
2044 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2045 assert_eq!(result.as_str(), "tested module structure");
2046
2047 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2048 assert_eq!(result.as_str(), "optimized module structure");
2049 }
2050
2051 #[test]
2052 fn test_fallback_summary_with_stat() {
2053 let config = CommitConfig::default();
2054 let stat = "src/main.rs | 10 +++++++---\n";
2055 let details = vec![];
2056 let result = fallback_summary(stat, &details, "feat", &config);
2057 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2058 }
2059
2060 #[test]
2061 fn test_fallback_summary_with_details() {
2062 let config = CommitConfig::default();
2063 let stat = "";
2064 let details = vec!["First detail here.".to_string()];
2065 let result = fallback_summary(stat, &details, "feat", &config);
2066 assert_eq!(result.as_str(), "First detail here");
2068 }
2069
2070 #[test]
2071 fn test_fallback_summary_no_stat_no_details() {
2072 let config = CommitConfig::default();
2073 let result = fallback_summary("", &[], "feat", &config);
2074 assert_eq!(result.as_str(), "Updated files");
2076 }
2077
2078 #[test]
2079 fn test_fallback_summary_type_word_overlap() {
2080 let config = CommitConfig::default();
2081 let details = vec!["refactor was performed".to_string()];
2082 let result = fallback_summary("", &details, "refactor", &config);
2083 assert_eq!(result.as_str(), "restructured change");
2085 }
2086
2087 #[test]
2088 fn test_fallback_summary_length_limit() {
2089 let config = CommitConfig::default();
2090 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
2092 let result = fallback_summary("", &details, "feat", &config);
2093 assert!(result.len() <= 50);
2095 }
2096}