llm_git/
api.rs

1use std::{thread, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6   config::CommitConfig,
7   error::{CommitGenError, Result},
8   templates,
9   tokens::TokenCounter,
10   types::{CommitSummary, ConventionalAnalysis},
11};
12
13// Prompts now loaded from config instead of compile-time constants
14
15/// Optional context information for commit analysis
16#[derive(Default)]
17pub struct AnalysisContext<'a> {
18   /// User-provided context
19   pub user_context:    Option<&'a str>,
20   /// Recent commits for style learning
21   pub recent_commits:  Option<&'a str>,
22   /// Common scopes for suggestions
23   pub common_scopes:   Option<&'a str>,
24   /// Project context (language, framework) for terminology
25   pub project_context: Option<&'a str>,
26}
27
28/// Build HTTP client with timeouts from config
29fn build_client(config: &CommitConfig) -> reqwest::blocking::Client {
30   reqwest::blocking::Client::builder()
31      .timeout(Duration::from_secs(config.request_timeout_secs))
32      .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
33      .build()
34      .expect("Failed to build HTTP client")
35}
36
37#[derive(Debug, Serialize)]
38struct Message {
39   role:    String,
40   content: String,
41}
42
43#[derive(Debug, Serialize, Deserialize)]
44struct FunctionParameters {
45   #[serde(rename = "type")]
46   param_type: String,
47   properties: serde_json::Value,
48   required:   Vec<String>,
49}
50
51#[derive(Debug, Serialize, Deserialize)]
52struct Function {
53   name:        String,
54   description: String,
55   parameters:  FunctionParameters,
56}
57
58#[derive(Debug, Serialize, Deserialize)]
59struct Tool {
60   #[serde(rename = "type")]
61   tool_type: String,
62   function:  Function,
63}
64
65#[derive(Debug, Serialize)]
66struct ApiRequest {
67   model:       String,
68   max_tokens:  u32,
69   temperature: f32,
70   tools:       Vec<Tool>,
71   #[serde(skip_serializing_if = "Option::is_none")]
72   tool_choice: Option<serde_json::Value>,
73   messages:    Vec<Message>,
74}
75
76#[derive(Debug, Deserialize)]
77struct ToolCall {
78   function: FunctionCall,
79}
80
81#[derive(Debug, Deserialize)]
82struct FunctionCall {
83   name:      String,
84   arguments: String,
85}
86
87#[derive(Debug, Deserialize)]
88struct Choice {
89   message: ResponseMessage,
90}
91
92#[derive(Debug, Deserialize)]
93struct ResponseMessage {
94   #[serde(default)]
95   tool_calls: Vec<ToolCall>,
96   #[serde(default)]
97   content:    Option<String>,
98}
99
100#[derive(Debug, Deserialize)]
101struct ApiResponse {
102   choices: Vec<Choice>,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
106struct SummaryOutput {
107   summary: String,
108}
109
110/// Retry an API call with exponential backoff
111pub fn retry_api_call<F, T>(config: &CommitConfig, mut f: F) -> Result<T>
112where
113   F: FnMut() -> Result<(bool, Option<T>)>,
114{
115   let mut attempt = 0;
116
117   loop {
118      attempt += 1;
119
120      match f() {
121         Ok((false, Some(result))) => return Ok(result),
122         Ok((false, None)) => {
123            return Err(CommitGenError::Other("API call failed without result".to_string()));
124         },
125         Ok((true, _)) if attempt < config.max_retries => {
126            let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
127            eprintln!(
128               "{}",
129               crate::style::warning(&format!(
130                  "Retry {}/{} after {}ms...",
131                  attempt, config.max_retries, backoff_ms
132               ))
133            );
134            thread::sleep(Duration::from_millis(backoff_ms));
135         },
136         Ok((true, _last_err)) => {
137            return Err(CommitGenError::ApiRetryExhausted {
138               retries: config.max_retries,
139               source:  Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
140            });
141         },
142         Err(e) => {
143            if attempt < config.max_retries {
144               let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
145               eprintln!(
146                  "{}",
147                  crate::style::warning(&format!(
148                     "Error: {} - Retry {}/{} after {}ms...",
149                     e, attempt, config.max_retries, backoff_ms
150                  ))
151               );
152               thread::sleep(Duration::from_millis(backoff_ms));
153               continue;
154            }
155            return Err(e);
156         },
157      }
158   }
159}
160
161/// Format commit types from config into a rich description for the prompt
162/// Order is preserved from config (first = highest priority)
163pub fn format_types_description(config: &CommitConfig) -> String {
164   use std::fmt::Write;
165   let mut out = String::from("Check types in order (first match wins):\n\n");
166
167   for (name, tc) in &config.types {
168      let _ = writeln!(out, "**{name}**: {}", tc.description);
169      if !tc.diff_indicators.is_empty() {
170         let _ = writeln!(out, "  Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
171      }
172      if !tc.file_patterns.is_empty() {
173         let _ = writeln!(out, "  File patterns: {}", tc.file_patterns.join(", "));
174      }
175      for ex in &tc.examples {
176         let _ = writeln!(out, "  - {ex}");
177      }
178      if !tc.hint.is_empty() {
179         let _ = writeln!(out, "  Note: {}", tc.hint);
180      }
181      out.push('\n');
182   }
183
184   if !config.classifier_hint.is_empty() {
185      let _ = writeln!(out, "\n{}", config.classifier_hint);
186   }
187
188   out
189}
190
191/// Generate conventional commit analysis using OpenAI-compatible API
192pub fn generate_conventional_analysis<'a>(
193   stat: &'a str,
194   diff: &'a str,
195   model_name: &'a str,
196   scope_candidates_str: &'a str,
197   ctx: &AnalysisContext<'a>,
198   config: &'a CommitConfig,
199) -> Result<ConventionalAnalysis> {
200   retry_api_call(config, move || {
201      let client = build_client(config);
202
203      // Build type enum from config
204      let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
205
206      // Define the conventional analysis tool
207      let tool = Tool {
208         tool_type: "function".to_string(),
209         function:  Function {
210            name:        "create_conventional_analysis".to_string(),
211            description: "Analyze changes and classify as conventional commit with type, scope, \
212                          details, and metadata"
213               .to_string(),
214            parameters:  FunctionParameters {
215               param_type: "object".to_string(),
216               properties: serde_json::json!({
217                  "type": {
218                     "type": "string",
219                     "enum": type_enum,
220                     "description": "Commit type based on change classification"
221                  },
222                  "scope": {
223                     "type": "string",
224                     "description": "Optional scope (module/component). Omit if unclear or multi-component."
225                  },
226                  "details": {
227                     "type": "array",
228                     "description": "Array of 0-6 detail items with changelog metadata.",
229                     "items": {
230                        "type": "object",
231                        "properties": {
232                           "text": {
233                              "type": "string",
234                              "description": "Detail about change, starting with past-tense verb, ending with period"
235                           },
236                           "changelog_category": {
237                              "type": "string",
238                              "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
239                              "description": "Changelog category if user-visible. Omit for internal changes."
240                           },
241                           "user_visible": {
242                              "type": "boolean",
243                              "description": "True if this change affects users/API and should appear in changelog"
244                           }
245                        },
246                        "required": ["text", "user_visible"]
247                     }
248                  },
249                  "issue_refs": {
250                     "type": "array",
251                     "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
252                     "items": {
253                        "type": "string"
254                     }
255                  }
256               }),
257               required:   vec![
258                  "type".to_string(),
259                  "details".to_string(),
260                  "issue_refs".to_string(),
261               ],
262            },
263         },
264      };
265
266      let request = ApiRequest {
267         model:       model_name.to_string(),
268         max_tokens:  1000,
269         temperature: config.temperature,
270         tools:       vec![tool],
271         tool_choice: Some(
272            serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
273         ),
274         messages:    vec![Message {
275            role:    "user".to_string(),
276            content: {
277               let types_desc = format_types_description(config);
278               let mut prompt = templates::render_analysis_prompt(&templates::AnalysisParams {
279                  variant: &config.analysis_prompt_variant,
280                  stat,
281                  diff,
282                  scope_candidates: scope_candidates_str,
283                  recent_commits: ctx.recent_commits,
284                  common_scopes: ctx.common_scopes,
285                  types_description: Some(&types_desc),
286                  project_context: ctx.project_context,
287               })?;
288
289               if let Some(user_ctx) = ctx.user_context {
290                  prompt = format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{prompt}");
291               }
292
293               prompt
294            },
295         }],
296      };
297
298      let mut request_builder = client
299         .post(format!("{}/chat/completions", config.api_base_url))
300         .header("content-type", "application/json");
301
302      // Add Authorization header if API key is configured
303      if let Some(api_key) = &config.api_key {
304         request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
305      }
306
307      let response = request_builder
308         .json(&request)
309         .send()
310         .map_err(CommitGenError::HttpError)?;
311
312      let status = response.status();
313
314      // Retry on 5xx errors
315      if status.is_server_error() {
316         let error_text = response
317            .text()
318            .unwrap_or_else(|_| "Unknown error".to_string());
319         eprintln!("{}", crate::style::error(&format!("Server error {status}: {error_text}")));
320         return Ok((true, None)); // Retry
321      }
322
323      if !status.is_success() {
324         let error_text = response
325            .text()
326            .unwrap_or_else(|_| "Unknown error".to_string());
327         return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
328      }
329
330      let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
331
332      if api_response.choices.is_empty() {
333         return Err(CommitGenError::Other(
334            "API returned empty response for change analysis".to_string(),
335         ));
336      }
337
338      let message = &api_response.choices[0].message;
339
340      // Find the tool call in the response
341      if !message.tool_calls.is_empty() {
342         let tool_call = &message.tool_calls[0];
343         if tool_call.function.name == "create_conventional_analysis" {
344            let args = &tool_call.function.arguments;
345            if args.is_empty() {
346               crate::style::warn(
347                  "Model returned empty function arguments. Model may not support function \
348                   calling properly.",
349               );
350               return Err(CommitGenError::Other(
351                  "Model returned empty function arguments - try using a Claude model \
352                   (sonnet/opus/haiku)"
353                     .to_string(),
354               ));
355            }
356            let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
357               CommitGenError::Other(format!(
358                  "Failed to parse model response: {}. Response was: {}",
359                  e,
360                  args.chars().take(200).collect::<String>()
361               ))
362            })?;
363            return Ok((false, Some(analysis)));
364         }
365      }
366
367      // Fallback: try to parse content as text
368      if let Some(content) = &message.content {
369         let analysis: ConventionalAnalysis =
370            serde_json::from_str(content.trim()).map_err(CommitGenError::JsonError)?;
371         return Ok((false, Some(analysis)));
372      }
373
374      Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
375   })
376}
377
378/// Strip conventional commit type prefix if LLM included it in summary.
379///
380/// Some models return the full format `feat(scope): summary` instead of just
381/// `summary`. This function removes the prefix to normalize the response.
382fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
383   let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
384   let prefix = format!("{commit_type}{scope_part}: ");
385
386   summary
387      .strip_prefix(&prefix)
388      .or_else(|| {
389         // Also try without scope in case model omitted it
390         let prefix_no_scope = format!("{commit_type}: ");
391         summary.strip_prefix(&prefix_no_scope)
392      })
393      .unwrap_or(summary)
394      .to_string()
395}
396
397/// Validate summary against requirements
398fn validate_summary_quality(
399   summary: &str,
400   commit_type: &str,
401   stat: &str,
402) -> std::result::Result<(), String> {
403   use crate::validation::is_past_tense_verb;
404
405   let first_word = summary
406      .split_whitespace()
407      .next()
408      .ok_or_else(|| "summary is empty".to_string())?;
409
410   let first_word_lower = first_word.to_lowercase();
411
412   // Check past-tense verb
413   if !is_past_tense_verb(&first_word_lower) {
414      return Err(format!(
415         "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
416      ));
417   }
418
419   // Check type repetition
420   if first_word_lower == commit_type {
421      return Err(format!("repeats commit type '{commit_type}' in summary"));
422   }
423
424   // Type-file mismatch heuristic
425   let file_exts: Vec<&str> = stat
426      .lines()
427      .filter_map(|line| {
428         let path = line.split('|').next()?.trim();
429         std::path::Path::new(path).extension()?.to_str()
430      })
431      .collect();
432
433   if !file_exts.is_empty() {
434      let total = file_exts.len();
435      let md_count = file_exts.iter().filter(|&&e| e == "md").count();
436
437      // If >80% markdown but not docs type, suggest docs
438      if md_count * 100 / total > 80 && commit_type != "docs" {
439         crate::style::warn(&format!(
440            "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
441            md_count * 100 / total,
442            commit_type
443         ));
444      }
445
446      // If no code files and type=feat/fix, warn
447      let code_exts = [
448         // Systems programming
449         "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
450         // JVM languages
451         "java", "kt", "kts", "scala", "groovy", "clj", "cljs", // .NET languages
452         "cs", "fs", "vb", // Web/scripting
453         "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", // Python ecosystem
454         "py", "pyx", "pxd", "pyi", // Ruby
455         "rb", "rake", "gemspec", // PHP
456         "php",     // Go
457         "go",      // Swift/Objective-C
458         "swift", "m", "mm",  // Lua
459         "lua", // Shell
460         "sh", "bash", "zsh", "fish", // Perl
461         "pl", "pm", // Haskell/ML family
462         "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
463         // Lisp family
464         "lisp", "cl", "el", "scm", "rkt", // Julia
465         "jl",  // R
466         "r", "R",    // Dart/Flutter
467         "dart", // Crystal
468         "cr",   // D
469         "d",    // Fortran
470         "f", "f90", "f95", "f03", "f08", // Ada
471         "ada", "adb", "ads", // Cobol
472         "cob", "cbl", // Assembly
473         "asm", "s", "S", // SQL (stored procs)
474         "sql", "plsql", // Prolog
475         "pl", "pro", // OCaml/ReasonML
476         "re", "rei", // Nix
477         "nix", // Terraform/HCL
478         "tf", "hcl",  // Solidity
479         "sol",  // Move
480         "move", // Cairo
481         "cairo",
482      ];
483      let code_count = file_exts
484         .iter()
485         .filter(|&&e| code_exts.contains(&e))
486         .count();
487      if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
488         crate::style::warn(&format!(
489            "Type mismatch: no code files changed but type is '{commit_type}'"
490         ));
491      }
492   }
493
494   Ok(())
495}
496
497/// Create commit summary using a smaller model focused on detail retention
498pub fn generate_summary_from_analysis<'a>(
499   stat: &'a str,
500   commit_type: &'a str,
501   scope: Option<&'a str>,
502   details: &'a [String],
503   user_context: Option<&'a str>,
504   config: &'a CommitConfig,
505) -> Result<CommitSummary> {
506   let mut validation_attempt = 0;
507   let max_validation_retries = 1;
508   let mut last_failure_reason: Option<String> = None;
509
510   loop {
511      let additional_constraint = if let Some(reason) = &last_failure_reason {
512         format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
513      } else {
514         String::new()
515      };
516
517      let result = retry_api_call(config, move || {
518         // Pass details as plain sentences (no numbering - prevents model parroting)
519         let bullet_points = details.join("\n");
520
521         let client = build_client(config);
522
523         let tool = Tool {
524            tool_type: "function".to_string(),
525            function:  Function {
526               name:        "create_commit_summary".to_string(),
527               description: "Compose a git commit summary line from detail statements".to_string(),
528               parameters:  FunctionParameters {
529                  param_type: "object".to_string(),
530                  properties: serde_json::json!({
531                     "summary": {
532                        "type": "string",
533                        "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
534                        "maxLength": config.summary_hard_limit
535                     }
536                  }),
537                  required:   vec!["summary".to_string()],
538               },
539            },
540         };
541
542         // Calculate guideline summary length accounting for "type(scope): " prefix
543         let scope_str = scope.unwrap_or("");
544         let prefix_len =
545            commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 }; // "type: " or "type(scope): "
546         let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
547
548         let request = ApiRequest {
549            model:       config.summary_model.clone(),
550            max_tokens:  200,
551            temperature: config.temperature,
552            tools:       vec![tool],
553            tool_choice: Some(serde_json::json!({
554               "type": "function",
555               "function": { "name": "create_commit_summary" }
556            })),
557            messages:    vec![Message {
558               role:    "user".to_string(),
559               content: {
560                  let details_str = if bullet_points.is_empty() {
561                     "None (no supporting detail points were generated)."
562                  } else {
563                     bullet_points.as_str()
564                  };
565
566                  let base_prompt = templates::render_summary_prompt(
567                     &config.summary_prompt_variant,
568                     commit_type,
569                     scope_str,
570                     &max_summary_len.to_string(),
571                     details_str,
572                     stat.trim(),
573                     user_context,
574                  )?;
575
576                  format!("{base_prompt}{additional_constraint}")
577               },
578            }],
579         };
580
581         let mut request_builder = client
582            .post(format!("{}/chat/completions", config.api_base_url))
583            .header("content-type", "application/json");
584
585         // Add Authorization header if API key is configured
586         if let Some(api_key) = &config.api_key {
587            request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
588         }
589
590         let response = request_builder
591            .json(&request)
592            .send()
593            .map_err(CommitGenError::HttpError)?;
594
595         let status = response.status();
596
597         // Retry on 5xx errors
598         if status.is_server_error() {
599            let error_text = response
600               .text()
601               .unwrap_or_else(|_| "Unknown error".to_string());
602            eprintln!("{}", crate::style::error(&format!("Server error {status}: {error_text}")));
603            return Ok((true, None)); // Retry
604         }
605
606         if !status.is_success() {
607            let error_text = response
608               .text()
609               .unwrap_or_else(|_| "Unknown error".to_string());
610            return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
611         }
612
613         let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
614
615         if api_response.choices.is_empty() {
616            return Err(CommitGenError::Other("Summary creation response was empty".to_string()));
617         }
618
619         let message_choice = &api_response.choices[0].message;
620
621         if !message_choice.tool_calls.is_empty() {
622            let tool_call = &message_choice.tool_calls[0];
623            if tool_call.function.name == "create_commit_summary" {
624               let args = &tool_call.function.arguments;
625               if args.is_empty() {
626                  crate::style::warn(
627                     "Model returned empty function arguments for summary. Model may not support \
628                      function calling.",
629                  );
630                  return Err(CommitGenError::Other(
631                     "Model returned empty summary arguments - try using a Claude model \
632                      (sonnet/opus/haiku)"
633                        .to_string(),
634                  ));
635               }
636               let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
637                  CommitGenError::Other(format!(
638                     "Failed to parse summary response: {}. Response was: {}",
639                     e,
640                     args.chars().take(200).collect::<String>()
641                  ))
642               })?;
643               // Strip type prefix if LLM included it (e.g., "feat(scope): summary" ->
644               // "summary")
645               let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
646               return Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)));
647            }
648         }
649
650         if let Some(content) = &message_choice.content {
651            let summary: SummaryOutput =
652               serde_json::from_str(content.trim()).map_err(CommitGenError::JsonError)?;
653            // Strip type prefix if LLM included it
654            let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
655            return Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)));
656         }
657
658         Err(CommitGenError::Other("No summary found in summary creation response".to_string()))
659      });
660
661      match result {
662         Ok(summary) => {
663            // Validate quality
664            match validate_summary_quality(summary.as_str(), commit_type, stat) {
665               Ok(()) => return Ok(summary),
666               Err(reason) if validation_attempt < max_validation_retries => {
667                  crate::style::warn(&format!(
668                     "Validation failed (attempt {}/{}): {}",
669                     validation_attempt + 1,
670                     max_validation_retries + 1,
671                     reason
672                  ));
673                  last_failure_reason = Some(reason);
674                  validation_attempt += 1;
675                  // Retry with constraint
676               },
677               Err(reason) => {
678                  crate::style::warn(&format!(
679                     "Validation failed after {} retries: {}. Using fallback.",
680                     max_validation_retries + 1,
681                     reason
682                  ));
683                  // Fallback: use first detail or heuristic
684                  return Ok(fallback_from_details_or_summary(
685                     details,
686                     summary.as_str(),
687                     commit_type,
688                     config,
689                  ));
690               },
691            }
692         },
693         Err(e) => return Err(e),
694      }
695   }
696}
697
698/// Fallback when validation fails: use first detail, strip type word if present
699fn fallback_from_details_or_summary(
700   details: &[String],
701   invalid_summary: &str,
702   commit_type: &str,
703   config: &CommitConfig,
704) -> CommitSummary {
705   let candidate = if let Some(first_detail) = details.first() {
706      // Use first detail line, strip type word
707      let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
708
709      // Remove type word if present at start
710      let type_word_variants =
711         [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
712      for variant in &type_word_variants {
713         if cleaned
714            .to_lowercase()
715            .starts_with(&format!("{} ", variant.to_lowercase()))
716         {
717            cleaned = cleaned[variant.len()..].trim().to_string();
718            break;
719         }
720      }
721
722      cleaned
723   } else {
724      // No details, try to fix invalid summary
725      let mut cleaned = invalid_summary
726         .split_whitespace()
727         .skip(1) // Remove first word (invalid verb)
728         .collect::<Vec<_>>()
729         .join(" ");
730
731      if cleaned.is_empty() {
732         cleaned = fallback_summary("", details, commit_type, config)
733            .as_str()
734            .to_string();
735      }
736
737      cleaned
738   };
739
740   // Ensure valid past-tense verb prefix
741   let with_verb = if candidate
742      .split_whitespace()
743      .next()
744      .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
745   {
746      candidate
747   } else {
748      let verb = match commit_type {
749         "feat" => "added",
750         "fix" => "fixed",
751         "refactor" => "restructured",
752         "docs" => "documented",
753         "test" => "tested",
754         "perf" => "optimized",
755         "build" | "ci" | "chore" => "updated",
756         "style" => "formatted",
757         "revert" => "reverted",
758         _ => "changed",
759      };
760      format!("{verb} {candidate}")
761   };
762
763   CommitSummary::new(with_verb, config.summary_hard_limit)
764      .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
765}
766
767/// Provide a deterministic fallback summary if model generation fails
768pub fn fallback_summary(
769   stat: &str,
770   details: &[String],
771   commit_type: &str,
772   config: &CommitConfig,
773) -> CommitSummary {
774   let mut candidate = if let Some(first) = details.first() {
775      first.trim().trim_end_matches('.').to_string()
776   } else {
777      let primary_line = stat
778         .lines()
779         .map(str::trim)
780         .find(|line| !line.is_empty())
781         .unwrap_or("files");
782
783      let subject = primary_line
784         .split('|')
785         .next()
786         .map(str::trim)
787         .filter(|s| !s.is_empty())
788         .unwrap_or("files");
789
790      if subject.eq_ignore_ascii_case("files") {
791         "Updated files".to_string()
792      } else {
793         format!("Updated {subject}")
794      }
795   };
796
797   candidate = candidate
798      .replace(['\n', '\r'], " ")
799      .split_whitespace()
800      .collect::<Vec<_>>()
801      .join(" ")
802      .trim()
803      .trim_end_matches('.')
804      .trim_end_matches(';')
805      .trim_end_matches(':')
806      .to_string();
807
808   if candidate.is_empty() {
809      candidate = "Updated files".to_string();
810   }
811
812   // Truncate to conservative length (50 chars) since we don't know the scope yet
813   // post_process_commit_message will truncate further if needed
814   const CONSERVATIVE_MAX: usize = 50;
815   while candidate.len() > CONSERVATIVE_MAX {
816      if let Some(pos) = candidate.rfind(' ') {
817         candidate.truncate(pos);
818         candidate = candidate.trim_end_matches(',').trim().to_string();
819      } else {
820         candidate.truncate(CONSERVATIVE_MAX);
821         break;
822      }
823   }
824
825   // Ensure no trailing period (conventional commits style)
826   candidate = candidate.trim_end_matches('.').to_string();
827
828   // If the candidate ended up identical to the commit type, replace with a safer
829   // default
830   if candidate
831      .split_whitespace()
832      .next()
833      .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
834   {
835      candidate = match commit_type {
836         "refactor" => "restructured change".to_string(),
837         "feat" => "added functionality".to_string(),
838         "fix" => "fixed issue".to_string(),
839         "docs" => "documented updates".to_string(),
840         "test" => "tested changes".to_string(),
841         "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
842         "perf" => "optimized performance".to_string(),
843         "revert" => "reverted previous commit".to_string(),
844         _ => "updated files".to_string(),
845      };
846   }
847
848   // Unwrap is safe: fallback_summary guarantees non-empty string ≤50 chars (<
849   // config limit)
850   CommitSummary::new(candidate, config.summary_hard_limit)
851      .expect("fallback summary should always be valid")
852}
853
854/// Generate conventional commit analysis, using map-reduce for large diffs
855///
856/// This is the main entry point for analysis. It automatically routes to
857/// map-reduce when the diff exceeds the configured token threshold.
858pub fn generate_analysis_with_map_reduce<'a>(
859   stat: &'a str,
860   diff: &'a str,
861   model_name: &'a str,
862   scope_candidates_str: &'a str,
863   ctx: &AnalysisContext<'a>,
864   config: &'a CommitConfig,
865   counter: &TokenCounter,
866) -> Result<ConventionalAnalysis> {
867   use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
868
869   if should_use_map_reduce(diff, config, counter) {
870      crate::style::print_info(&format!(
871         "Large diff detected ({} tokens), using map-reduce...",
872         counter.count_sync(diff)
873      ));
874      run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter)
875   } else {
876      generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
877   }
878}
879
880#[cfg(test)]
881mod tests {
882   use super::*;
883   use crate::config::CommitConfig;
884
885   #[test]
886   fn test_validate_summary_quality_valid() {
887      let stat = "src/main.rs | 10 +++++++---\n";
888      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
889      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
890      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
891   }
892
893   #[test]
894   fn test_validate_summary_quality_invalid_verb() {
895      let stat = "src/main.rs | 10 +++++++---\n";
896      let result = validate_summary_quality("adding new feature", "feat", stat);
897      assert!(result.is_err());
898      assert!(result.unwrap_err().contains("past-tense verb"));
899   }
900
901   #[test]
902   fn test_validate_summary_quality_type_repetition() {
903      let stat = "src/main.rs | 10 +++++++---\n";
904      // "feat" is not a past-tense verb so it should fail on verb check first
905      let result = validate_summary_quality("feat new feature", "feat", stat);
906      assert!(result.is_err());
907      assert!(result.unwrap_err().contains("past-tense verb"));
908
909      // "fixed" is past-tense but repeats "fix" type
910      let result = validate_summary_quality("fix bug", "fix", stat);
911      assert!(result.is_err());
912      // "fix" is not in PAST_TENSE_VERBS, so fails on verb check
913      assert!(result.unwrap_err().contains("past-tense verb"));
914   }
915
916   #[test]
917   fn test_validate_summary_quality_empty() {
918      let stat = "src/main.rs | 10 +++++++---\n";
919      let result = validate_summary_quality("", "feat", stat);
920      assert!(result.is_err());
921      assert!(result.unwrap_err().contains("empty"));
922   }
923
924   #[test]
925   fn test_validate_summary_quality_markdown_type_mismatch() {
926      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
927      // Should warn but not fail
928      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
929   }
930
931   #[test]
932   fn test_validate_summary_quality_no_code_files() {
933      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
934      // Should warn but not fail
935      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
936   }
937
938   #[test]
939   fn test_fallback_from_details_with_first_detail() {
940      let config = CommitConfig::default();
941      let details = vec![
942         "Added authentication middleware.".to_string(),
943         "Updated error handling.".to_string(),
944      ];
945      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
946      // Capital A preserved from detail
947      assert_eq!(result.as_str(), "Added authentication middleware");
948   }
949
950   #[test]
951   fn test_fallback_from_details_strips_type_word() {
952      let config = CommitConfig::default();
953      let details = vec!["Featuring new oauth flow.".to_string()];
954      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
955      // Should strip "Featuring" (present participle, not past tense) and add valid
956      // verb
957      assert!(result.as_str().starts_with("added"));
958   }
959
960   #[test]
961   fn test_fallback_from_details_no_details() {
962      let config = CommitConfig::default();
963      let details: Vec<String> = vec![];
964      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
965      // Should use rest of summary or fallback
966      assert!(result.as_str().starts_with("added"));
967   }
968
969   #[test]
970   fn test_fallback_from_details_adds_verb() {
971      let config = CommitConfig::default();
972      let details = vec!["configuration for oauth".to_string()];
973      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
974      assert_eq!(result.as_str(), "added configuration for oauth");
975   }
976
977   #[test]
978   fn test_fallback_from_details_preserves_existing_verb() {
979      let config = CommitConfig::default();
980      let details = vec!["fixed authentication bug".to_string()];
981      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
982      assert_eq!(result.as_str(), "fixed authentication bug");
983   }
984
985   #[test]
986   fn test_fallback_from_details_type_specific_verbs() {
987      let config = CommitConfig::default();
988      let details = vec!["module structure".to_string()];
989
990      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
991      assert_eq!(result.as_str(), "restructured module structure");
992
993      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
994      assert_eq!(result.as_str(), "documented module structure");
995
996      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
997      assert_eq!(result.as_str(), "tested module structure");
998
999      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
1000      assert_eq!(result.as_str(), "optimized module structure");
1001   }
1002
1003   #[test]
1004   fn test_fallback_summary_with_stat() {
1005      let config = CommitConfig::default();
1006      let stat = "src/main.rs | 10 +++++++---\n";
1007      let details = vec![];
1008      let result = fallback_summary(stat, &details, "feat", &config);
1009      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
1010   }
1011
1012   #[test]
1013   fn test_fallback_summary_with_details() {
1014      let config = CommitConfig::default();
1015      let stat = "";
1016      let details = vec!["First detail here.".to_string()];
1017      let result = fallback_summary(stat, &details, "feat", &config);
1018      // Capital F preserved
1019      assert_eq!(result.as_str(), "First detail here");
1020   }
1021
1022   #[test]
1023   fn test_fallback_summary_no_stat_no_details() {
1024      let config = CommitConfig::default();
1025      let result = fallback_summary("", &[], "feat", &config);
1026      // Fallback returns "Updated files" when no stat/details
1027      assert_eq!(result.as_str(), "Updated files");
1028   }
1029
1030   #[test]
1031   fn test_fallback_summary_type_word_overlap() {
1032      let config = CommitConfig::default();
1033      let details = vec!["refactor was performed".to_string()];
1034      let result = fallback_summary("", &details, "refactor", &config);
1035      // Should replace "refactor" with type-specific verb
1036      assert_eq!(result.as_str(), "restructured change");
1037   }
1038
1039   #[test]
1040   fn test_fallback_summary_length_limit() {
1041      let config = CommitConfig::default();
1042      let long_detail = "a ".repeat(100); // 200 chars
1043      let details = vec![long_detail.trim().to_string()];
1044      let result = fallback_summary("", &details, "feat", &config);
1045      // Should truncate to conservative max (50 chars)
1046      assert!(result.len() <= 50);
1047   }
1048}