Skip to main content

llm_git/
api.rs

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, ConventionalAnalysis},
11};
12
13/// Whether API tracing is enabled (`LLM_GIT_TRACE=1`).
14static TRACE_ENABLED: OnceLock<bool> = OnceLock::new();
15
16/// Check if API request tracing is enabled via `LLM_GIT_TRACE` env var.
17fn trace_enabled() -> bool {
18   *TRACE_ENABLED.get_or_init(|| std::env::var("LLM_GIT_TRACE").is_ok())
19}
20
21/// Send an HTTP request with timing instrumentation.
22///
23/// Measures TTFT (time to first byte / headers received) separately from total
24/// response time. Logs to stderr when `LLM_GIT_TRACE=1`.
25pub 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      // Clear spinner line before printing (spinner writes \r to stdout)
51      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// Prompts now loaded from config instead of compile-time constants
65
66/// Optional context information for commit analysis
67#[derive(Default)]
68pub struct AnalysisContext<'a> {
69   /// User-provided context
70   pub user_context:    Option<&'a str>,
71   /// Recent commits for style learning
72   pub recent_commits:  Option<&'a str>,
73   /// Common scopes for suggestions
74   pub common_scopes:   Option<&'a str>,
75   /// Project context (language, framework) for terminology
76   pub project_context: Option<&'a str>,
77   /// Debug output directory for saving raw I/O
78   pub debug_output:    Option<&'a Path>,
79   /// Prefix for debug output files to avoid collisions
80   pub debug_prefix:    Option<&'a str>,
81}
82
83/// Shared HTTP client, lazily initialized on first use.
84static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
85
86/// Get (or create) the shared HTTP client with timeouts from config.
87///
88/// The first call initializes the client with the given config's timeouts;
89/// subsequent calls reuse the same client regardless of config values.
90pub 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 extract_anthropic_content(
140   response_text: &str,
141   tool_name: &str,
142) -> Result<(Option<serde_json::Value>, String)> {
143   let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
144      CommitGenError::Other(format!(
145         "Failed to parse Anthropic response JSON: {e}. Response body: {}",
146         response_snippet(response_text, 500)
147      ))
148   })?;
149
150   let mut tool_input: Option<serde_json::Value> = None;
151   let mut text_parts = Vec::new();
152
153   if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
154      for item in content {
155         let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
156         match item_type {
157            "tool_use" => {
158               let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
159               if name == tool_name
160                  && let Some(input) = item.get("input")
161               {
162                  tool_input = Some(input.clone());
163               }
164            },
165            "text" => {
166               if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
167                  text_parts.push(text.to_string());
168               }
169            },
170            _ => {},
171         }
172      }
173   }
174
175   Ok((tool_input, text_parts.join("\n")))
176}
177
178#[derive(Debug, Serialize)]
179struct Message {
180   role:    String,
181   content: String,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185struct FunctionParameters {
186   #[serde(rename = "type")]
187   param_type: String,
188   properties: serde_json::Value,
189   required:   Vec<String>,
190}
191
192#[derive(Debug, Serialize, Deserialize)]
193struct Function {
194   name:        String,
195   description: String,
196   parameters:  FunctionParameters,
197}
198
199#[derive(Debug, Serialize, Deserialize)]
200struct Tool {
201   #[serde(rename = "type")]
202   tool_type: String,
203   function:  Function,
204}
205
206#[derive(Debug, Serialize)]
207struct ApiRequest {
208   model:       String,
209   max_tokens:  u32,
210   temperature: f32,
211   tools:       Vec<Tool>,
212   #[serde(skip_serializing_if = "Option::is_none")]
213   tool_choice: Option<serde_json::Value>,
214   messages:    Vec<Message>,
215}
216
217#[derive(Debug, Serialize)]
218struct AnthropicRequest {
219   model:       String,
220   max_tokens:  u32,
221   temperature: f32,
222   #[serde(skip_serializing_if = "Option::is_none")]
223   system:      Option<String>,
224   tools:       Vec<AnthropicTool>,
225   #[serde(skip_serializing_if = "Option::is_none")]
226   tool_choice: Option<AnthropicToolChoice>,
227   messages:    Vec<AnthropicMessage>,
228}
229
230#[derive(Debug, Serialize)]
231struct AnthropicTool {
232   name:         String,
233   description:  String,
234   input_schema: serde_json::Value,
235}
236
237#[derive(Debug, Serialize)]
238struct AnthropicToolChoice {
239   #[serde(rename = "type")]
240   choice_type: String,
241   name:        String,
242}
243
244#[derive(Debug, Serialize)]
245struct AnthropicMessage {
246   role:    String,
247   content: Vec<AnthropicContent>,
248}
249
250#[derive(Debug, Serialize)]
251struct AnthropicContent {
252   #[serde(rename = "type")]
253   content_type: String,
254   text:         String,
255}
256
257#[derive(Debug, Deserialize)]
258struct ToolCall {
259   function: FunctionCall,
260}
261
262#[derive(Debug, Deserialize)]
263struct FunctionCall {
264   name:      String,
265   arguments: String,
266}
267
268#[derive(Debug, Deserialize)]
269struct Choice {
270   message: ResponseMessage,
271}
272
273#[derive(Debug, Deserialize)]
274struct ResponseMessage {
275   #[serde(default)]
276   tool_calls: Vec<ToolCall>,
277   #[serde(default)]
278   content:    Option<String>,
279}
280
281#[derive(Debug, Deserialize)]
282struct ApiResponse {
283   choices: Vec<Choice>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287struct SummaryOutput {
288   summary: String,
289}
290
291/// Retry an API call with exponential backoff
292pub async fn retry_api_call<T>(
293   config: &CommitConfig,
294   mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
295) -> Result<T> {
296   let mut attempt = 0;
297
298   loop {
299      attempt += 1;
300
301      match f().await {
302         Ok((false, Some(result))) => return Ok(result),
303         Ok((false, None)) => {
304            return Err(CommitGenError::Other("API call failed without result".to_string()));
305         },
306         Ok((true, _)) if attempt < config.max_retries => {
307            let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
308            eprintln!(
309               "{}",
310               crate::style::warning(&format!(
311                  "Retry {}/{} after {}ms...",
312                  attempt, config.max_retries, backoff_ms
313               ))
314            );
315            tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
316         },
317         Ok((true, _last_err)) => {
318            return Err(CommitGenError::ApiRetryExhausted {
319               retries: config.max_retries,
320               source:  Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
321            });
322         },
323         Err(e) => {
324            if attempt < config.max_retries {
325               let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
326               eprintln!(
327                  "{}",
328                  crate::style::warning(&format!(
329                     "Error: {} - Retry {}/{} after {}ms...",
330                     e, attempt, config.max_retries, backoff_ms
331                  ))
332               );
333               tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
334               continue;
335            }
336            return Err(e);
337         },
338      }
339   }
340}
341
342/// Format commit types from config into a rich description for the prompt
343/// Order is preserved from config (first = highest priority)
344pub fn format_types_description(config: &CommitConfig) -> String {
345   use std::fmt::Write;
346   let mut out = String::from("Check types in order (first match wins):\n\n");
347
348   for (name, tc) in &config.types {
349      let _ = writeln!(out, "**{name}**: {}", tc.description);
350      if !tc.diff_indicators.is_empty() {
351         let _ = writeln!(out, "  Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
352      }
353      if !tc.file_patterns.is_empty() {
354         let _ = writeln!(out, "  File patterns: {}", tc.file_patterns.join(", "));
355      }
356      for ex in &tc.examples {
357         let _ = writeln!(out, "  - {ex}");
358      }
359      if !tc.hint.is_empty() {
360         let _ = writeln!(out, "  Note: {}", tc.hint);
361      }
362      out.push('\n');
363   }
364
365   if !config.classifier_hint.is_empty() {
366      let _ = writeln!(out, "\n{}", config.classifier_hint);
367   }
368
369   out
370}
371
372/// Generate conventional commit analysis using OpenAI-compatible API
373pub async fn generate_conventional_analysis<'a>(
374   stat: &'a str,
375   diff: &'a str,
376   model_name: &'a str,
377   scope_candidates_str: &'a str,
378   ctx: &AnalysisContext<'a>,
379   config: &'a CommitConfig,
380) -> Result<ConventionalAnalysis> {
381   retry_api_call(config, async move || {
382      let client = get_client(config);
383
384      // Build type enum from config
385      let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
386
387      // Define the conventional analysis tool
388      let tool = Tool {
389         tool_type: "function".to_string(),
390         function:  Function {
391            name:        "create_conventional_analysis".to_string(),
392            description: "Analyze changes and classify as conventional commit with type, scope, \
393                          details, and metadata"
394               .to_string(),
395            parameters:  FunctionParameters {
396               param_type: "object".to_string(),
397               properties: serde_json::json!({
398                  "type": {
399                     "type": "string",
400                     "enum": type_enum,
401                     "description": "Commit type based on change classification"
402                  },
403                  "scope": {
404                     "type": "string",
405                     "description": "Optional scope (module/component). Omit if unclear or multi-component."
406                  },
407                  "details": {
408                     "type": "array",
409                     "description": "Array of 0-6 detail items with changelog metadata.",
410                     "items": {
411                        "type": "object",
412                        "properties": {
413                           "text": {
414                              "type": "string",
415                              "description": "Detail about change, starting with past-tense verb, ending with period"
416                           },
417                           "changelog_category": {
418                              "type": "string",
419                              "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
420                              "description": "Changelog category if user-visible. Omit for internal changes."
421                           },
422                           "user_visible": {
423                              "type": "boolean",
424                              "description": "True if this change affects users/API and should appear in changelog"
425                           }
426                        },
427                        "required": ["text", "user_visible"]
428                     }
429                  },
430                  "issue_refs": {
431                     "type": "array",
432                     "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
433                     "items": {
434                        "type": "string"
435                     }
436                  }
437               }),
438               required:   vec![
439                  "type".to_string(),
440                  "details".to_string(),
441                  "issue_refs".to_string(),
442               ],
443            },
444         },
445      };
446
447      let debug_dir = ctx.debug_output;
448      let debug_prefix = ctx.debug_prefix;
449      let mode = config.resolved_api_mode(model_name);
450
451      let response_text = match mode {
452         ResolvedApiMode::ChatCompletions => {
453            let types_desc = format_types_description(config);
454            let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
455               variant: &config.analysis_prompt_variant,
456               stat,
457               diff,
458               scope_candidates: scope_candidates_str,
459               recent_commits: ctx.recent_commits,
460               common_scopes: ctx.common_scopes,
461               types_description: Some(&types_desc),
462               project_context: ctx.project_context,
463            })?;
464
465            let user_content = if let Some(user_ctx) = ctx.user_context {
466               format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
467            } else {
468               parts.user
469            };
470
471            let request = ApiRequest {
472               model:       model_name.to_string(),
473               max_tokens:  1000,
474               temperature: config.temperature,
475               tools:       vec![tool],
476               tool_choice: Some(
477                  serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
478               ),
479               messages:    vec![
480                  Message { role: "system".to_string(), content: parts.system },
481                  Message { role: "user".to_string(), content: user_content },
482               ],
483            };
484
485            if debug_dir.is_some() {
486               let request_json = serde_json::to_string_pretty(&request)?;
487               save_debug_output(
488                  debug_dir,
489                  &debug_filename(debug_prefix, "analysis_request.json"),
490                  &request_json,
491               )?;
492            }
493
494            let mut request_builder = client
495               .post(format!("{}/chat/completions", config.api_base_url))
496               .header("content-type", "application/json");
497
498            // Add Authorization header if API key is configured
499            if let Some(api_key) = &config.api_key {
500               request_builder =
501                  request_builder.header("Authorization", format!("Bearer {api_key}"));
502            }
503
504            let (status, response_text) =
505               timed_send(request_builder.json(&request), "analysis", model_name).await?;
506            if debug_dir.is_some() {
507               save_debug_output(
508                  debug_dir,
509                  &debug_filename(debug_prefix, "analysis_response.json"),
510                  &response_text,
511               )?;
512            }
513
514            // Retry on 5xx errors
515            if status.is_server_error() {
516               eprintln!(
517                  "{}",
518                  crate::style::error(&format!("Server error {status}: {response_text}"))
519               );
520               return Ok((true, None)); // Retry
521            }
522
523            if !status.is_success() {
524               return Err(CommitGenError::ApiError {
525                  status: status.as_u16(),
526                  body:   response_text,
527               });
528            }
529
530            response_text
531         },
532         ResolvedApiMode::AnthropicMessages => {
533            let types_desc = format_types_description(config);
534            let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
535               variant: &config.analysis_prompt_variant,
536               stat,
537               diff,
538               scope_candidates: scope_candidates_str,
539               recent_commits: ctx.recent_commits,
540               common_scopes: ctx.common_scopes,
541               types_description: Some(&types_desc),
542               project_context: ctx.project_context,
543            })?;
544
545            let user_content = if let Some(user_ctx) = ctx.user_context {
546               format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
547            } else {
548               parts.user
549            };
550
551            let request = AnthropicRequest {
552               model:       model_name.to_string(),
553               max_tokens:  1000,
554               temperature: config.temperature,
555               system:      Some(parts.system).filter(|s| !s.is_empty()),
556               tools:       vec![AnthropicTool {
557                  name:         "create_conventional_analysis".to_string(),
558                  description:  "Analyze changes and classify as conventional commit with type, \
559                                 scope, details, and metadata"
560                     .to_string(),
561                  input_schema: serde_json::json!({
562                     "type": "object",
563                     "properties": {
564                        "type": {
565                           "type": "string",
566                           "enum": type_enum,
567                           "description": "Commit type based on change classification"
568                        },
569                        "scope": {
570                           "type": "string",
571                           "description": "Optional scope (module/component). Omit if unclear or multi-component."
572                        },
573                        "details": {
574                           "type": "array",
575                           "description": "Array of 0-6 detail items with changelog metadata.",
576                           "items": {
577                              "type": "object",
578                              "properties": {
579                                 "text": {
580                                    "type": "string",
581                                    "description": "Detail about change, starting with past-tense verb, ending with period"
582                                 },
583                                 "changelog_category": {
584                                    "type": "string",
585                                    "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
586                                    "description": "Changelog category if user-visible. Omit for internal changes."
587                                 },
588                                 "user_visible": {
589                                    "type": "boolean",
590                                    "description": "True if this change affects users/API and should appear in changelog"
591                                 }
592                              },
593                              "required": ["text", "user_visible"]
594                           }
595                        },
596                        "issue_refs": {
597                           "type": "array",
598                           "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
599                           "items": {
600                              "type": "string"
601                           }
602                        }
603                     },
604                     "required": ["type", "details", "issue_refs"]
605                  }),
606               }],
607               tool_choice: Some(AnthropicToolChoice {
608                  choice_type: "tool".to_string(),
609                  name:        "create_conventional_analysis".to_string(),
610               }),
611               messages:    vec![AnthropicMessage {
612                  role:    "user".to_string(),
613                  content: vec![AnthropicContent {
614                     content_type: "text".to_string(),
615                     text:         user_content,
616                  }],
617               }],
618            };
619
620            if debug_dir.is_some() {
621               let request_json = serde_json::to_string_pretty(&request)?;
622               save_debug_output(
623                  debug_dir,
624                  &debug_filename(debug_prefix, "analysis_request.json"),
625                  &request_json,
626               )?;
627            }
628
629            let mut request_builder = client
630               .post(anthropic_messages_url(&config.api_base_url))
631               .header("content-type", "application/json")
632               .header("anthropic-version", "2023-06-01");
633
634            if let Some(api_key) = &config.api_key {
635               request_builder = request_builder.header("x-api-key", api_key);
636            }
637
638            let (status, response_text) =
639               timed_send(request_builder.json(&request), "analysis", model_name).await?;
640            if debug_dir.is_some() {
641               save_debug_output(
642                  debug_dir,
643                  &debug_filename(debug_prefix, "analysis_response.json"),
644                  &response_text,
645               )?;
646            }
647
648            if status.is_server_error() {
649               eprintln!(
650                  "{}",
651                  crate::style::error(&format!("Server error {status}: {response_text}"))
652               );
653               return Ok((true, None));
654            }
655
656            if !status.is_success() {
657               return Err(CommitGenError::ApiError {
658                  status: status.as_u16(),
659                  body:   response_text,
660               });
661            }
662
663            response_text
664         },
665      };
666
667      if response_text.trim().is_empty() {
668         crate::style::warn("Model returned empty response body for analysis; retrying.");
669         return Ok((true, None));
670      }
671
672      match mode {
673         ResolvedApiMode::ChatCompletions => {
674            let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
675               CommitGenError::Other(format!(
676                  "Failed to parse analysis response JSON: {e}. Response body: {}",
677                  response_snippet(&response_text, 500)
678               ))
679            })?;
680
681            if api_response.choices.is_empty() {
682               return Err(CommitGenError::Other(
683                  "API returned empty response for change analysis".to_string(),
684               ));
685            }
686
687            let message = &api_response.choices[0].message;
688
689            // Find the tool call in the response
690            if !message.tool_calls.is_empty() {
691               let tool_call = &message.tool_calls[0];
692               if tool_call
693                  .function
694                  .name
695                  .ends_with("create_conventional_analysis")
696               {
697                  let args = &tool_call.function.arguments;
698                  if args.is_empty() {
699                     crate::style::warn(
700                        "Model returned empty function arguments. Model may not support function \
701                         calling properly.",
702                     );
703                     return Err(CommitGenError::Other(
704                        "Model returned empty function arguments - try using a Claude model \
705                         (sonnet/opus/haiku)"
706                           .to_string(),
707                     ));
708                  }
709                  let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
710                     CommitGenError::Other(format!(
711                        "Failed to parse model response: {}. Response was: {}",
712                        e,
713                        args.chars().take(200).collect::<String>()
714                     ))
715                  })?;
716                  return Ok((false, Some(analysis)));
717               }
718            }
719
720            // Fallback: try to parse content as text
721            if let Some(content) = &message.content {
722               if content.trim().is_empty() {
723                  crate::style::warn("Model returned empty content for analysis; retrying.");
724                  return Ok((true, None));
725               }
726               let analysis: ConventionalAnalysis =
727                  serde_json::from_str(content.trim()).map_err(|e| {
728                     CommitGenError::Other(format!(
729                        "Failed to parse analysis content JSON: {e}. Content: {}",
730                        response_snippet(content, 500)
731                     ))
732                  })?;
733               return Ok((false, Some(analysis)));
734            }
735
736            Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
737         },
738         ResolvedApiMode::AnthropicMessages => {
739            let (tool_input, text_content) =
740               extract_anthropic_content(&response_text, "create_conventional_analysis")?;
741
742            if let Some(input) = tool_input {
743               let analysis: ConventionalAnalysis = serde_json::from_value(input).map_err(|e| {
744                  CommitGenError::Other(format!(
745                     "Failed to parse analysis tool input: {e}. Response body: {}",
746                     response_snippet(&response_text, 500)
747                  ))
748               })?;
749               return Ok((false, Some(analysis)));
750            }
751
752            if text_content.trim().is_empty() {
753               crate::style::warn("Model returned empty content for analysis; retrying.");
754               return Ok((true, None));
755            }
756
757            let analysis: ConventionalAnalysis = serde_json::from_str(text_content.trim())
758               .map_err(|e| {
759                  CommitGenError::Other(format!(
760                     "Failed to parse analysis content JSON: {e}. Content: {}",
761                     response_snippet(&text_content, 500)
762                  ))
763               })?;
764            Ok((false, Some(analysis)))
765         },
766      }
767   }).await
768}
769
770/// Strip conventional commit type prefix if LLM included it in summary.
771///
772/// Some models return the full format `feat(scope): summary` instead of just
773/// `summary`. This function removes the prefix to normalize the response.
774fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
775   let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
776   let prefix = format!("{commit_type}{scope_part}: ");
777
778   summary
779      .strip_prefix(&prefix)
780      .or_else(|| {
781         // Also try without scope in case model omitted it
782         let prefix_no_scope = format!("{commit_type}: ");
783         summary.strip_prefix(&prefix_no_scope)
784      })
785      .unwrap_or(summary)
786      .to_string()
787}
788
789/// Validate summary against requirements
790fn validate_summary_quality(
791   summary: &str,
792   commit_type: &str,
793   stat: &str,
794) -> std::result::Result<(), String> {
795   use crate::validation::is_past_tense_verb;
796
797   let first_word = summary
798      .split_whitespace()
799      .next()
800      .ok_or_else(|| "summary is empty".to_string())?;
801
802   let first_word_lower = first_word.to_lowercase();
803
804   // Check past-tense verb
805   if !is_past_tense_verb(&first_word_lower) {
806      return Err(format!(
807         "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
808      ));
809   }
810
811   // Check type repetition
812   if first_word_lower == commit_type {
813      return Err(format!("repeats commit type '{commit_type}' in summary"));
814   }
815
816   // Type-file mismatch heuristic
817   let file_exts: Vec<&str> = stat
818      .lines()
819      .filter_map(|line| {
820         let path = line.split('|').next()?.trim();
821         std::path::Path::new(path).extension()?.to_str()
822      })
823      .collect();
824
825   if !file_exts.is_empty() {
826      let total = file_exts.len();
827      let md_count = file_exts.iter().filter(|&&e| e == "md").count();
828
829      // If >80% markdown but not docs type, suggest docs
830      if md_count * 100 / total > 80 && commit_type != "docs" {
831         crate::style::warn(&format!(
832            "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
833            md_count * 100 / total,
834            commit_type
835         ));
836      }
837
838      // If no code files and type=feat/fix, warn
839      let code_exts = [
840         // Systems programming
841         "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
842         // JVM languages
843         "java", "kt", "kts", "scala", "groovy", "clj", "cljs", // .NET languages
844         "cs", "fs", "vb", // Web/scripting
845         "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", // Python ecosystem
846         "py", "pyx", "pxd", "pyi", // Ruby
847         "rb", "rake", "gemspec", // PHP
848         "php",     // Go
849         "go",      // Swift/Objective-C
850         "swift", "m", "mm",  // Lua
851         "lua", // Shell
852         "sh", "bash", "zsh", "fish", // Perl
853         "pl", "pm", // Haskell/ML family
854         "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
855         // Lisp family
856         "lisp", "cl", "el", "scm", "rkt", // Julia
857         "jl",  // R
858         "r", "R",    // Dart/Flutter
859         "dart", // Crystal
860         "cr",   // D
861         "d",    // Fortran
862         "f", "f90", "f95", "f03", "f08", // Ada
863         "ada", "adb", "ads", // Cobol
864         "cob", "cbl", // Assembly
865         "asm", "s", "S", // SQL (stored procs)
866         "sql", "plsql", // Prolog
867         "pl", "pro", // OCaml/ReasonML
868         "re", "rei", // Nix
869         "nix", // Terraform/HCL
870         "tf", "hcl",  // Solidity
871         "sol",  // Move
872         "move", // Cairo
873         "cairo",
874      ];
875      let code_count = file_exts
876         .iter()
877         .filter(|&&e| code_exts.contains(&e))
878         .count();
879      if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
880         crate::style::warn(&format!(
881            "Type mismatch: no code files changed but type is '{commit_type}'"
882         ));
883      }
884   }
885
886   Ok(())
887}
888
889/// Create commit summary using a smaller model focused on detail retention
890#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
891pub async fn generate_summary_from_analysis<'a>(
892   stat: &'a str,
893   commit_type: &'a str,
894   scope: Option<&'a str>,
895   details: &'a [String],
896   user_context: Option<&'a str>,
897   config: &'a CommitConfig,
898   debug_dir: Option<&'a Path>,
899   debug_prefix: Option<&'a str>,
900) -> Result<CommitSummary> {
901   let mut validation_attempt = 0;
902   let max_validation_retries = 1;
903   let mut last_failure_reason: Option<String> = None;
904
905   loop {
906      let additional_constraint = if let Some(reason) = &last_failure_reason {
907         format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
908      } else {
909         String::new()
910      };
911
912      let result = retry_api_call(config, async move || {
913         // Pass details as plain sentences (no numbering - prevents model parroting)
914         let bullet_points = details.join("\n");
915
916         let client = get_client(config);
917
918         let tool = Tool {
919            tool_type: "function".to_string(),
920            function:  Function {
921               name:        "create_commit_summary".to_string(),
922               description: "Compose a git commit summary line from detail statements".to_string(),
923               parameters:  FunctionParameters {
924                  param_type: "object".to_string(),
925                  properties: serde_json::json!({
926                     "summary": {
927                        "type": "string",
928                        "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
929                        "maxLength": config.summary_hard_limit
930                     }
931                  }),
932                  required:   vec!["summary".to_string()],
933               },
934            },
935         };
936
937         // Calculate guideline summary length accounting for "type(scope): " prefix
938         let scope_str = scope.unwrap_or("");
939         let prefix_len =
940            commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 }; // "type: " or "type(scope): "
941         let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
942
943         let mode = config.resolved_api_mode(&config.model);
944
945         let response_text = match mode {
946            ResolvedApiMode::ChatCompletions => {
947               let details_str = if bullet_points.is_empty() {
948                  "None (no supporting detail points were generated)."
949               } else {
950                  bullet_points.as_str()
951               };
952
953               let parts = templates::render_summary_prompt(
954                  &config.summary_prompt_variant,
955                  commit_type,
956                  scope_str,
957                  &max_summary_len.to_string(),
958                  details_str,
959                  stat.trim(),
960                  user_context,
961               )?;
962
963               let user_content = format!("{}{additional_constraint}", parts.user);
964
965               let request = ApiRequest {
966                  model:       config.model.clone(),
967                  max_tokens:  200,
968                  temperature: config.temperature,
969                  tools:       vec![tool],
970                  tool_choice: Some(serde_json::json!({
971                     "type": "function",
972                     "function": { "name": "create_commit_summary" }
973                  })),
974                  messages:    vec![
975                     Message { role: "system".to_string(), content: parts.system },
976                     Message { role: "user".to_string(), content: user_content },
977                  ],
978               };
979
980               if debug_dir.is_some() {
981                  let request_json = serde_json::to_string_pretty(&request)?;
982                  save_debug_output(
983                     debug_dir,
984                     &debug_filename(debug_prefix, "summary_request.json"),
985                     &request_json,
986                  )?;
987               }
988
989               let mut request_builder = client
990                  .post(format!("{}/chat/completions", config.api_base_url))
991                  .header("content-type", "application/json");
992
993               // Add Authorization header if API key is configured
994               if let Some(api_key) = &config.api_key {
995                  request_builder =
996                     request_builder.header("Authorization", format!("Bearer {api_key}"));
997               }
998
999               let (status, response_text) =
1000                  timed_send(request_builder.json(&request), "summary", &config.model).await?;
1001               if debug_dir.is_some() {
1002                  save_debug_output(
1003                     debug_dir,
1004                     &debug_filename(debug_prefix, "summary_response.json"),
1005                     &response_text,
1006                  )?;
1007               }
1008
1009               // Retry on 5xx errors
1010               if status.is_server_error() {
1011                  eprintln!(
1012                     "{}",
1013                     crate::style::error(&format!("Server error {status}: {response_text}"))
1014                  );
1015                  return Ok((true, None)); // Retry
1016               }
1017
1018               if !status.is_success() {
1019                  return Err(CommitGenError::ApiError {
1020                     status: status.as_u16(),
1021                     body:   response_text,
1022                  });
1023               }
1024
1025               response_text
1026            },
1027            ResolvedApiMode::AnthropicMessages => {
1028               let details_str = if bullet_points.is_empty() {
1029                  "None (no supporting detail points were generated)."
1030               } else {
1031                  bullet_points.as_str()
1032               };
1033
1034               let parts = templates::render_summary_prompt(
1035                  &config.summary_prompt_variant,
1036                  commit_type,
1037                  scope_str,
1038                  &max_summary_len.to_string(),
1039                  details_str,
1040                  stat.trim(),
1041                  user_context,
1042               )?;
1043
1044               let user_content = format!("{}{additional_constraint}", parts.user);
1045
1046               let request = AnthropicRequest {
1047                  model:       config.model.clone(),
1048                  max_tokens:  200,
1049                  temperature: config.temperature,
1050                  system:      Some(parts.system).filter(|s| !s.is_empty()),
1051                  tools:       vec![AnthropicTool {
1052                     name:         "create_commit_summary".to_string(),
1053                     description:  "Compose a git commit summary line from detail statements"
1054                        .to_string(),
1055                     input_schema: serde_json::json!({
1056                        "type": "object",
1057                        "properties": {
1058                           "summary": {
1059                              "type": "string",
1060                              "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1061                              "maxLength": config.summary_hard_limit
1062                           }
1063                        },
1064                        "required": ["summary"]
1065                     }),
1066                  }],
1067                  tool_choice: Some(AnthropicToolChoice {
1068                     choice_type: "tool".to_string(),
1069                     name:        "create_commit_summary".to_string(),
1070                  }),
1071                  messages:    vec![AnthropicMessage {
1072                     role:    "user".to_string(),
1073                     content: vec![AnthropicContent {
1074                        content_type: "text".to_string(),
1075                        text:         user_content,
1076                     }],
1077                  }],
1078               };
1079
1080               if debug_dir.is_some() {
1081                  let request_json = serde_json::to_string_pretty(&request)?;
1082                  save_debug_output(
1083                     debug_dir,
1084                     &debug_filename(debug_prefix, "summary_request.json"),
1085                     &request_json,
1086                  )?;
1087               }
1088
1089               let mut request_builder = client
1090                  .post(anthropic_messages_url(&config.api_base_url))
1091                  .header("content-type", "application/json")
1092                  .header("anthropic-version", "2023-06-01");
1093
1094               if let Some(api_key) = &config.api_key {
1095                  request_builder = request_builder.header("x-api-key", api_key);
1096               }
1097
1098               let (status, response_text) =
1099                  timed_send(request_builder.json(&request), "summary", &config.model).await?;
1100               if debug_dir.is_some() {
1101                  save_debug_output(
1102                     debug_dir,
1103                     &debug_filename(debug_prefix, "summary_response.json"),
1104                     &response_text,
1105                  )?;
1106               }
1107
1108               // Retry on 5xx errors
1109               if status.is_server_error() {
1110                  eprintln!(
1111                     "{}",
1112                     crate::style::error(&format!("Server error {status}: {response_text}"))
1113                  );
1114                  return Ok((true, None)); // Retry
1115               }
1116
1117               if !status.is_success() {
1118                  return Err(CommitGenError::ApiError {
1119                     status: status.as_u16(),
1120                     body:   response_text,
1121                  });
1122               }
1123
1124               response_text
1125            },
1126         };
1127
1128         if response_text.trim().is_empty() {
1129            crate::style::warn("Model returned empty response body for summary; retrying.");
1130            return Ok((true, None));
1131         }
1132
1133         match mode {
1134            ResolvedApiMode::ChatCompletions => {
1135               let api_response: ApiResponse =
1136                  serde_json::from_str(&response_text).map_err(|e| {
1137                     CommitGenError::Other(format!(
1138                        "Failed to parse summary response JSON: {e}. Response body: {}",
1139                        response_snippet(&response_text, 500)
1140                     ))
1141                  })?;
1142
1143               if api_response.choices.is_empty() {
1144                  return Err(CommitGenError::Other(
1145                     "Summary creation response was empty".to_string(),
1146                  ));
1147               }
1148
1149               let message_choice = &api_response.choices[0].message;
1150
1151               if !message_choice.tool_calls.is_empty() {
1152                  let tool_call = &message_choice.tool_calls[0];
1153                  if tool_call.function.name.ends_with("create_commit_summary") {
1154                     let args = &tool_call.function.arguments;
1155                     if args.is_empty() {
1156                        crate::style::warn(
1157                           "Model returned empty function arguments for summary. Model may not \
1158                            support function calling.",
1159                        );
1160                        return Err(CommitGenError::Other(
1161                           "Model returned empty summary arguments - try using a Claude model \
1162                            (sonnet/opus/haiku)"
1163                              .to_string(),
1164                        ));
1165                     }
1166                     let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
1167                        CommitGenError::Other(format!(
1168                           "Failed to parse summary response: {}. Response was: {}",
1169                           e,
1170                           args.chars().take(200).collect::<String>()
1171                        ))
1172                     })?;
1173                     // Strip type prefix if LLM included it (e.g., "feat(scope): summary" ->
1174                     // "summary")
1175                     let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1176                     return Ok((
1177                        false,
1178                        Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1179                     ));
1180                  }
1181               }
1182
1183               if let Some(content) = &message_choice.content {
1184                  if content.trim().is_empty() {
1185                     crate::style::warn("Model returned empty content for summary; retrying.");
1186                     return Ok((true, None));
1187                  }
1188                  // Try JSON first, fall back to plain text (for models without function calling)
1189                  let trimmed = content.trim();
1190                  let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1191                     Ok(summary) => summary.summary,
1192                     Err(e) => {
1193                        // Only use plain text if it doesn't look like JSON
1194                        if trimmed.starts_with('{') {
1195                           return Err(CommitGenError::Other(format!(
1196                              "Failed to parse summary JSON: {e}. Content: {}",
1197                              response_snippet(trimmed, 500)
1198                           )));
1199                        }
1200                        // Model returned plain text instead of JSON - use it directly
1201                        trimmed.to_string()
1202                     },
1203                  };
1204                  // Strip type prefix if LLM included it
1205                  let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1206                  return Ok((
1207                     false,
1208                     Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1209                  ));
1210               }
1211
1212               Err(CommitGenError::Other(
1213                  "No summary found in summary creation response".to_string(),
1214               ))
1215            },
1216            ResolvedApiMode::AnthropicMessages => {
1217               let (tool_input, text_content) =
1218                  extract_anthropic_content(&response_text, "create_commit_summary")?;
1219
1220               if let Some(input) = tool_input {
1221                  let summary: SummaryOutput = serde_json::from_value(input).map_err(|e| {
1222                     CommitGenError::Other(format!(
1223                        "Failed to parse summary tool input: {e}. Response body: {}",
1224                        response_snippet(&response_text, 500)
1225                     ))
1226                  })?;
1227                  let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1228                  return Ok((
1229                     false,
1230                     Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1231                  ));
1232               }
1233
1234               if text_content.trim().is_empty() {
1235                  crate::style::warn("Model returned empty content for summary; retrying.");
1236                  return Ok((true, None));
1237               }
1238
1239               // Try JSON first, fall back to plain text (for models without function calling)
1240               let trimmed = text_content.trim();
1241               let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1242                  Ok(summary) => summary.summary,
1243                  Err(e) => {
1244                     // Only use plain text if it doesn't look like JSON
1245                     if trimmed.starts_with('{') {
1246                        return Err(CommitGenError::Other(format!(
1247                           "Failed to parse summary JSON: {e}. Content: {}",
1248                           response_snippet(trimmed, 500)
1249                        )));
1250                     }
1251                     // Model returned plain text instead of JSON - use it directly
1252                     trimmed.to_string()
1253                  },
1254               };
1255               let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1256               Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)))
1257            },
1258         }
1259      }).await;
1260
1261      match result {
1262         Ok(summary) => {
1263            // Validate quality
1264            match validate_summary_quality(summary.as_str(), commit_type, stat) {
1265               Ok(()) => return Ok(summary),
1266               Err(reason) if validation_attempt < max_validation_retries => {
1267                  crate::style::warn(&format!(
1268                     "Validation failed (attempt {}/{}): {}",
1269                     validation_attempt + 1,
1270                     max_validation_retries + 1,
1271                     reason
1272                  ));
1273                  last_failure_reason = Some(reason);
1274                  validation_attempt += 1;
1275                  // Retry with constraint
1276               },
1277               Err(reason) => {
1278                  crate::style::warn(&format!(
1279                     "Validation failed after {} retries: {}. Using fallback.",
1280                     max_validation_retries + 1,
1281                     reason
1282                  ));
1283                  // Fallback: use first detail or heuristic
1284                  return Ok(fallback_from_details_or_summary(
1285                     details,
1286                     summary.as_str(),
1287                     commit_type,
1288                     config,
1289                  ));
1290               },
1291            }
1292         },
1293         Err(e) => return Err(e),
1294      }
1295   }
1296}
1297
1298/// Fallback when validation fails: use first detail, strip type word if present
1299fn fallback_from_details_or_summary(
1300   details: &[String],
1301   invalid_summary: &str,
1302   commit_type: &str,
1303   config: &CommitConfig,
1304) -> CommitSummary {
1305   let candidate = if let Some(first_detail) = details.first() {
1306      // Use first detail line, strip type word
1307      let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1308
1309      // Remove type word if present at start
1310      let type_word_variants =
1311         [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1312      for variant in &type_word_variants {
1313         if cleaned
1314            .to_lowercase()
1315            .starts_with(&format!("{} ", variant.to_lowercase()))
1316         {
1317            cleaned = cleaned[variant.len()..].trim().to_string();
1318            break;
1319         }
1320      }
1321
1322      cleaned
1323   } else {
1324      // No details, try to fix invalid summary
1325      let mut cleaned = invalid_summary
1326         .split_whitespace()
1327         .skip(1) // Remove first word (invalid verb)
1328         .collect::<Vec<_>>()
1329         .join(" ");
1330
1331      if cleaned.is_empty() {
1332         cleaned = fallback_summary("", details, commit_type, config)
1333            .as_str()
1334            .to_string();
1335      }
1336
1337      cleaned
1338   };
1339
1340   // Ensure valid past-tense verb prefix
1341   let with_verb = if candidate
1342      .split_whitespace()
1343      .next()
1344      .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
1345   {
1346      candidate
1347   } else {
1348      let verb = match commit_type {
1349         "feat" => "added",
1350         "fix" => "fixed",
1351         "refactor" => "restructured",
1352         "docs" => "documented",
1353         "test" => "tested",
1354         "perf" => "optimized",
1355         "build" | "ci" | "chore" => "updated",
1356         "style" => "formatted",
1357         "revert" => "reverted",
1358         _ => "changed",
1359      };
1360      format!("{verb} {candidate}")
1361   };
1362
1363   CommitSummary::new(with_verb, config.summary_hard_limit)
1364      .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1365}
1366
1367/// Provide a deterministic fallback summary if model generation fails
1368pub fn fallback_summary(
1369   stat: &str,
1370   details: &[String],
1371   commit_type: &str,
1372   config: &CommitConfig,
1373) -> CommitSummary {
1374   let mut candidate = if let Some(first) = details.first() {
1375      first.trim().trim_end_matches('.').to_string()
1376   } else {
1377      let primary_line = stat
1378         .lines()
1379         .map(str::trim)
1380         .find(|line| !line.is_empty())
1381         .unwrap_or("files");
1382
1383      let subject = primary_line
1384         .split('|')
1385         .next()
1386         .map(str::trim)
1387         .filter(|s| !s.is_empty())
1388         .unwrap_or("files");
1389
1390      if subject.eq_ignore_ascii_case("files") {
1391         "Updated files".to_string()
1392      } else {
1393         format!("Updated {subject}")
1394      }
1395   };
1396
1397   candidate = candidate
1398      .replace(['\n', '\r'], " ")
1399      .split_whitespace()
1400      .collect::<Vec<_>>()
1401      .join(" ")
1402      .trim()
1403      .trim_end_matches('.')
1404      .trim_end_matches(';')
1405      .trim_end_matches(':')
1406      .to_string();
1407
1408   if candidate.is_empty() {
1409      candidate = "Updated files".to_string();
1410   }
1411
1412   // Truncate to conservative length (50 chars) since we don't know the scope yet
1413   // post_process_commit_message will truncate further if needed
1414   const CONSERVATIVE_MAX: usize = 50;
1415   while candidate.len() > CONSERVATIVE_MAX {
1416      if let Some(pos) = candidate.rfind(' ') {
1417         candidate.truncate(pos);
1418         candidate = candidate.trim_end_matches(',').trim().to_string();
1419      } else {
1420         candidate.truncate(CONSERVATIVE_MAX);
1421         break;
1422      }
1423   }
1424
1425   // Ensure no trailing period (conventional commits style)
1426   candidate = candidate.trim_end_matches('.').to_string();
1427
1428   // If the candidate ended up identical to the commit type, replace with a safer
1429   // default
1430   if candidate
1431      .split_whitespace()
1432      .next()
1433      .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1434   {
1435      candidate = match commit_type {
1436         "refactor" => "restructured change".to_string(),
1437         "feat" => "added functionality".to_string(),
1438         "fix" => "fixed issue".to_string(),
1439         "docs" => "documented updates".to_string(),
1440         "test" => "tested changes".to_string(),
1441         "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1442         "perf" => "optimized performance".to_string(),
1443         "revert" => "reverted previous commit".to_string(),
1444         _ => "updated files".to_string(),
1445      };
1446   }
1447
1448   // Unwrap is safe: fallback_summary guarantees non-empty string ≤50 chars (<
1449   // config limit)
1450   CommitSummary::new(candidate, config.summary_hard_limit)
1451      .expect("fallback summary should always be valid")
1452}
1453
1454/// Generate conventional commit analysis, using map-reduce for large diffs
1455///
1456/// This is the main entry point for analysis. It automatically routes to
1457/// map-reduce when the diff exceeds the configured token threshold.
1458pub async fn generate_analysis_with_map_reduce<'a>(
1459   stat: &'a str,
1460   diff: &'a str,
1461   model_name: &'a str,
1462   scope_candidates_str: &'a str,
1463   ctx: &AnalysisContext<'a>,
1464   config: &'a CommitConfig,
1465   counter: &TokenCounter,
1466) -> Result<ConventionalAnalysis> {
1467   use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1468
1469   if should_use_map_reduce(diff, config, counter) {
1470      crate::style::print_info(&format!(
1471         "Large diff detected ({} tokens), using map-reduce...",
1472         counter.count_sync(diff)
1473      ));
1474      run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1475   } else {
1476      generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1477         .await
1478   }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483   use super::*;
1484   use crate::config::CommitConfig;
1485
1486   #[test]
1487   fn test_validate_summary_quality_valid() {
1488      let stat = "src/main.rs | 10 +++++++---\n";
1489      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
1490      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
1491      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
1492   }
1493
1494   #[test]
1495   fn test_validate_summary_quality_invalid_verb() {
1496      let stat = "src/main.rs | 10 +++++++---\n";
1497      let result = validate_summary_quality("adding new feature", "feat", stat);
1498      assert!(result.is_err());
1499      assert!(result.unwrap_err().contains("past-tense verb"));
1500   }
1501
1502   #[test]
1503   fn test_validate_summary_quality_type_repetition() {
1504      let stat = "src/main.rs | 10 +++++++---\n";
1505      // "feat" is not a past-tense verb so it should fail on verb check first
1506      let result = validate_summary_quality("feat new feature", "feat", stat);
1507      assert!(result.is_err());
1508      assert!(result.unwrap_err().contains("past-tense verb"));
1509
1510      // "fixed" is past-tense but repeats "fix" type
1511      let result = validate_summary_quality("fix bug", "fix", stat);
1512      assert!(result.is_err());
1513      // "fix" is not in PAST_TENSE_VERBS, so fails on verb check
1514      assert!(result.unwrap_err().contains("past-tense verb"));
1515   }
1516
1517   #[test]
1518   fn test_validate_summary_quality_empty() {
1519      let stat = "src/main.rs | 10 +++++++---\n";
1520      let result = validate_summary_quality("", "feat", stat);
1521      assert!(result.is_err());
1522      assert!(result.unwrap_err().contains("empty"));
1523   }
1524
1525   #[test]
1526   fn test_validate_summary_quality_markdown_type_mismatch() {
1527      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
1528      // Should warn but not fail
1529      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
1530   }
1531
1532   #[test]
1533   fn test_validate_summary_quality_no_code_files() {
1534      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
1535      // Should warn but not fail
1536      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
1537   }
1538
1539   #[test]
1540   fn test_fallback_from_details_with_first_detail() {
1541      let config = CommitConfig::default();
1542      let details = vec![
1543         "Added authentication middleware.".to_string(),
1544         "Updated error handling.".to_string(),
1545      ];
1546      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
1547      // Capital A preserved from detail
1548      assert_eq!(result.as_str(), "Added authentication middleware");
1549   }
1550
1551   #[test]
1552   fn test_fallback_from_details_strips_type_word() {
1553      let config = CommitConfig::default();
1554      let details = vec!["Featuring new oauth flow.".to_string()];
1555      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
1556      // Should strip "Featuring" (present participle, not past tense) and add valid
1557      // verb
1558      assert!(result.as_str().starts_with("added"));
1559   }
1560
1561   #[test]
1562   fn test_fallback_from_details_no_details() {
1563      let config = CommitConfig::default();
1564      let details: Vec<String> = vec![];
1565      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
1566      // Should use rest of summary or fallback
1567      assert!(result.as_str().starts_with("added"));
1568   }
1569
1570   #[test]
1571   fn test_fallback_from_details_adds_verb() {
1572      let config = CommitConfig::default();
1573      let details = vec!["configuration for oauth".to_string()];
1574      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
1575      assert_eq!(result.as_str(), "added configuration for oauth");
1576   }
1577
1578   #[test]
1579   fn test_fallback_from_details_preserves_existing_verb() {
1580      let config = CommitConfig::default();
1581      let details = vec!["fixed authentication bug".to_string()];
1582      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
1583      assert_eq!(result.as_str(), "fixed authentication bug");
1584   }
1585
1586   #[test]
1587   fn test_fallback_from_details_type_specific_verbs() {
1588      let config = CommitConfig::default();
1589      let details = vec!["module structure".to_string()];
1590
1591      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
1592      assert_eq!(result.as_str(), "restructured module structure");
1593
1594      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
1595      assert_eq!(result.as_str(), "documented module structure");
1596
1597      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
1598      assert_eq!(result.as_str(), "tested module structure");
1599
1600      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
1601      assert_eq!(result.as_str(), "optimized module structure");
1602   }
1603
1604   #[test]
1605   fn test_fallback_summary_with_stat() {
1606      let config = CommitConfig::default();
1607      let stat = "src/main.rs | 10 +++++++---\n";
1608      let details = vec![];
1609      let result = fallback_summary(stat, &details, "feat", &config);
1610      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
1611   }
1612
1613   #[test]
1614   fn test_fallback_summary_with_details() {
1615      let config = CommitConfig::default();
1616      let stat = "";
1617      let details = vec!["First detail here.".to_string()];
1618      let result = fallback_summary(stat, &details, "feat", &config);
1619      // Capital F preserved
1620      assert_eq!(result.as_str(), "First detail here");
1621   }
1622
1623   #[test]
1624   fn test_fallback_summary_no_stat_no_details() {
1625      let config = CommitConfig::default();
1626      let result = fallback_summary("", &[], "feat", &config);
1627      // Fallback returns "Updated files" when no stat/details
1628      assert_eq!(result.as_str(), "Updated files");
1629   }
1630
1631   #[test]
1632   fn test_fallback_summary_type_word_overlap() {
1633      let config = CommitConfig::default();
1634      let details = vec!["refactor was performed".to_string()];
1635      let result = fallback_summary("", &details, "refactor", &config);
1636      // Should replace "refactor" with type-specific verb
1637      assert_eq!(result.as_str(), "restructured change");
1638   }
1639
1640   #[test]
1641   fn test_fallback_summary_length_limit() {
1642      let config = CommitConfig::default();
1643      let long_detail = "a ".repeat(100); // 200 chars
1644      let details = vec![long_detail.trim().to_string()];
1645      let result = fallback_summary("", &details, "feat", &config);
1646      // Should truncate to conservative max (50 chars)
1647      assert!(result.len() <= 50);
1648   }
1649}