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   types::{CommitSummary, ConventionalAnalysis},
10};
11
12// Prompts now loaded from config instead of compile-time constants
13
14/// Build HTTP client with timeouts from config
15fn build_client(config: &CommitConfig) -> reqwest::blocking::Client {
16   reqwest::blocking::Client::builder()
17      .timeout(Duration::from_secs(config.request_timeout_secs))
18      .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
19      .build()
20      .expect("Failed to build HTTP client")
21}
22
23#[derive(Debug, Serialize)]
24struct Message {
25   role:    String,
26   content: String,
27}
28
29#[derive(Debug, Serialize, Deserialize)]
30struct FunctionParameters {
31   #[serde(rename = "type")]
32   param_type: String,
33   properties: serde_json::Value,
34   required:   Vec<String>,
35}
36
37#[derive(Debug, Serialize, Deserialize)]
38struct Function {
39   name:        String,
40   description: String,
41   parameters:  FunctionParameters,
42}
43
44#[derive(Debug, Serialize, Deserialize)]
45struct Tool {
46   #[serde(rename = "type")]
47   tool_type: String,
48   function:  Function,
49}
50
51#[derive(Debug, Serialize)]
52struct ApiRequest {
53   model:       String,
54   max_tokens:  u32,
55   temperature: f32,
56   tools:       Vec<Tool>,
57   #[serde(skip_serializing_if = "Option::is_none")]
58   tool_choice: Option<serde_json::Value>,
59   messages:    Vec<Message>,
60}
61
62#[derive(Debug, Deserialize)]
63struct ToolCall {
64   function: FunctionCall,
65}
66
67#[derive(Debug, Deserialize)]
68struct FunctionCall {
69   name:      String,
70   arguments: String,
71}
72
73#[derive(Debug, Deserialize)]
74struct Choice {
75   message: ResponseMessage,
76}
77
78#[derive(Debug, Deserialize)]
79struct ResponseMessage {
80   #[serde(default)]
81   tool_calls: Vec<ToolCall>,
82   #[serde(default)]
83   content:    Option<String>,
84}
85
86#[derive(Debug, Deserialize)]
87struct ApiResponse {
88   choices: Vec<Choice>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92struct SummaryOutput {
93   summary: String,
94}
95
96/// Retry an API call with exponential backoff
97pub fn retry_api_call<F, T>(config: &CommitConfig, mut f: F) -> Result<T>
98where
99   F: FnMut() -> Result<(bool, Option<T>)>,
100{
101   let mut attempt = 0;
102
103   loop {
104      attempt += 1;
105
106      match f() {
107         Ok((false, Some(result))) => return Ok(result),
108         Ok((false, None)) => {
109            return Err(CommitGenError::Other("API call failed without result".to_string()));
110         },
111         Ok((true, _)) if attempt < config.max_retries => {
112            let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
113            eprintln!("Retry {}/{} after {}ms...", attempt, config.max_retries, backoff_ms);
114            thread::sleep(Duration::from_millis(backoff_ms));
115         },
116         Ok((true, _last_err)) => {
117            return Err(CommitGenError::ApiRetryExhausted {
118               retries: config.max_retries,
119               source:  Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
120            });
121         },
122         Err(e) => {
123            if attempt < config.max_retries {
124               let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
125               eprintln!(
126                  "Error: {} - Retry {}/{} after {}ms...",
127                  e, attempt, config.max_retries, backoff_ms
128               );
129               thread::sleep(Duration::from_millis(backoff_ms));
130               continue;
131            }
132            return Err(e);
133         },
134      }
135   }
136}
137
138/// Generate conventional commit analysis using OpenAI-compatible API
139pub fn generate_conventional_analysis<'a>(
140   stat: &'a str,
141   diff: &'a str,
142   model_name: &'a str,
143   context: Option<&'a str>,
144   scope_candidates_str: &'a str,
145   config: &'a CommitConfig,
146) -> Result<ConventionalAnalysis> {
147   retry_api_call(config, move || {
148      let client = build_client(config);
149
150      // Define the conventional analysis tool
151      let tool = Tool {
152         tool_type: "function".to_string(),
153         function:  Function {
154            name:        "create_conventional_analysis".to_string(),
155            description: "Analyze changes and classify as conventional commit with type, scope, \
156                          details, and metadata"
157               .to_string(),
158            parameters:  FunctionParameters {
159               param_type: "object".to_string(),
160               properties: serde_json::json!({
161                  "type": {
162                     "type": "string",
163                     "enum": ["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"],
164                     "description": "Commit type based on change classification"
165                  },
166                  "scope": {
167                     "type": "string",
168                     "description": "Optional scope (module/component). Omit if unclear or multi-component."
169                  },
170                  "body": {
171                     "type": "array",
172                     "description": "Array of 0-6 detail items (empty if no supporting details).",
173                     "items": {
174                        "type": "string",
175                        "description": "Detail about change, starting with past-tense verb, ending with period"
176                     }
177                  },
178                  "issue_refs": {
179                     "type": "array",
180                     "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
181                     "items": {
182                        "type": "string"
183                     }
184                  }
185               }),
186               required:   vec!["type".to_string(), "body".to_string(), "issue_refs".to_string()],
187            },
188         },
189      };
190
191      let request = ApiRequest {
192         model:       model_name.to_string(),
193         max_tokens:  1000,
194         temperature: config.temperature,
195         tools:       vec![tool],
196         tool_choice: Some(
197            serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
198         ),
199         messages:    vec![Message {
200            role:    "user".to_string(),
201            content: {
202               let mut prompt = templates::render_analysis_prompt(
203                  &config.analysis_prompt_variant,
204                  stat,
205                  diff,
206                  scope_candidates_str,
207               )?;
208
209               if let Some(ctx) = context {
210                  prompt = format!("ADDITIONAL CONTEXT FROM USER:\n{ctx}\n\n{prompt}");
211               }
212
213               prompt
214            },
215         }],
216      };
217
218      let mut request_builder = client
219         .post(format!("{}/chat/completions", config.api_base_url))
220         .header("content-type", "application/json");
221
222      // Add Authorization header if API key is configured
223      if let Some(ref api_key) = config.api_key {
224         request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
225      }
226
227      let response = request_builder
228         .json(&request)
229         .send()
230         .map_err(CommitGenError::HttpError)?;
231
232      let status = response.status();
233
234      // Retry on 5xx errors
235      if status.is_server_error() {
236         let error_text = response
237            .text()
238            .unwrap_or_else(|_| "Unknown error".to_string());
239         eprintln!("Server error {status}: {error_text}");
240         return Ok((true, None)); // Retry
241      }
242
243      if !status.is_success() {
244         let error_text = response
245            .text()
246            .unwrap_or_else(|_| "Unknown error".to_string());
247         return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
248      }
249
250      let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
251
252      if api_response.choices.is_empty() {
253         return Err(CommitGenError::Other(
254            "API returned empty response for change analysis".to_string(),
255         ));
256      }
257
258      let message = &api_response.choices[0].message;
259
260      // Find the tool call in the response
261      if !message.tool_calls.is_empty() {
262         let tool_call = &message.tool_calls[0];
263         if tool_call.function.name == "create_conventional_analysis" {
264            let args = &tool_call.function.arguments;
265            if args.is_empty() {
266               eprintln!(
267                  "Warning: Model returned empty function arguments. Model may not support \
268                   function calling properly."
269               );
270               return Err(CommitGenError::Other(
271                  "Model returned empty function arguments - try using a Claude model \
272                   (sonnet/opus/haiku)"
273                     .to_string(),
274               ));
275            }
276            let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
277               CommitGenError::Other(format!(
278                  "Failed to parse model response: {}. Response was: {}",
279                  e,
280                  args.chars().take(200).collect::<String>()
281               ))
282            })?;
283            return Ok((false, Some(analysis)));
284         }
285      }
286
287      // Fallback: try to parse content as text
288      if let Some(content) = &message.content {
289         let analysis: ConventionalAnalysis =
290            serde_json::from_str(content.trim()).map_err(CommitGenError::JsonError)?;
291         return Ok((false, Some(analysis)));
292      }
293
294      Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
295   })
296}
297
298/// Validate summary against requirements
299fn validate_summary_quality(
300   summary: &str,
301   commit_type: &str,
302   stat: &str,
303) -> std::result::Result<(), String> {
304   use crate::validation::is_past_tense_verb;
305
306   let first_word = summary
307      .split_whitespace()
308      .next()
309      .ok_or_else(|| "summary is empty".to_string())?;
310
311   let first_word_lower = first_word.to_lowercase();
312
313   // Check past-tense verb
314   if !is_past_tense_verb(&first_word_lower) {
315      return Err(format!(
316         "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
317      ));
318   }
319
320   // Check type repetition
321   if first_word_lower == commit_type {
322      return Err(format!("repeats commit type '{commit_type}' in summary"));
323   }
324
325   // Type-file mismatch heuristic
326   let file_exts: Vec<&str> = stat
327      .lines()
328      .filter_map(|line| {
329         let path = line.split('|').next()?.trim();
330         std::path::Path::new(path).extension()?.to_str()
331      })
332      .collect();
333
334   if !file_exts.is_empty() {
335      let total = file_exts.len();
336      let md_count = file_exts.iter().filter(|&&e| e == "md").count();
337
338      // If >80% markdown but not docs type, suggest docs
339      if md_count * 100 / total > 80 && commit_type != "docs" {
340         eprintln!(
341            "⚠ Type mismatch: {}% .md files but type is '{}' (consider docs type)",
342            md_count * 100 / total,
343            commit_type
344         );
345      }
346
347      // If no code files and type=feat/fix, warn
348      let code_exts = ["rs", "py", "js", "ts", "go", "java", "c", "cpp"];
349      let code_count = file_exts
350         .iter()
351         .filter(|&&e| code_exts.contains(&e))
352         .count();
353      if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
354         eprintln!("⚠ Type mismatch: no code files changed but type is '{commit_type}'");
355      }
356   }
357
358   Ok(())
359}
360
361/// Create commit summary using a smaller model focused on detail retention
362pub fn generate_summary_from_analysis<'a>(
363   stat: &'a str,
364   commit_type: &'a str,
365   scope: Option<&'a str>,
366   details: &'a [String],
367   user_context: Option<&'a str>,
368   config: &'a CommitConfig,
369) -> Result<CommitSummary> {
370   let mut validation_attempt = 0;
371   let max_validation_retries = 1;
372   let mut last_failure_reason: Option<String> = None;
373
374   loop {
375      let additional_constraint = if let Some(ref reason) = last_failure_reason {
376         format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
377      } else {
378         String::new()
379      };
380
381      let result = retry_api_call(config, move || {
382         // Pass details as plain sentences (no numbering - prevents model parroting)
383         let bullet_points = details.join("\n");
384
385         let client = build_client(config);
386
387         let tool = Tool {
388            tool_type: "function".to_string(),
389            function:  Function {
390               name:        "create_commit_summary".to_string(),
391               description: "Compose a git commit summary line from detail statements".to_string(),
392               parameters:  FunctionParameters {
393                  param_type: "object".to_string(),
394                  properties: serde_json::json!({
395                     "summary": {
396                        "type": "string",
397                        "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
398                        "maxLength": config.summary_hard_limit
399                     }
400                  }),
401                  required:   vec!["summary".to_string()],
402               },
403            },
404         };
405
406         // Calculate guideline summary length accounting for "type(scope): " prefix
407         let scope_str = scope.unwrap_or("");
408         let prefix_len =
409            commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 }; // "type: " or "type(scope): "
410         let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
411
412         let request = ApiRequest {
413            model:       config.summary_model.clone(),
414            max_tokens:  200,
415            temperature: config.temperature,
416            tools:       vec![tool],
417            tool_choice: Some(serde_json::json!({
418               "type": "function",
419               "function": { "name": "create_commit_summary" }
420            })),
421            messages:    vec![Message {
422               role:    "user".to_string(),
423               content: {
424                  let details_str = if bullet_points.is_empty() {
425                     "None (no supporting detail points were generated)."
426                  } else {
427                     bullet_points.as_str()
428                  };
429
430                  let base_prompt = templates::render_summary_prompt(
431                     &config.summary_prompt_variant,
432                     commit_type,
433                     scope_str,
434                     &max_summary_len.to_string(),
435                     details_str,
436                     stat.trim(),
437                     user_context,
438                  )?;
439
440                  format!("{base_prompt}{additional_constraint}")
441               },
442            }],
443         };
444
445         let mut request_builder = client
446            .post(format!("{}/chat/completions", config.api_base_url))
447            .header("content-type", "application/json");
448
449         // Add Authorization header if API key is configured
450         if let Some(ref api_key) = config.api_key {
451            request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
452         }
453
454         let response = request_builder
455            .json(&request)
456            .send()
457            .map_err(CommitGenError::HttpError)?;
458
459         let status = response.status();
460
461         // Retry on 5xx errors
462         if status.is_server_error() {
463            let error_text = response
464               .text()
465               .unwrap_or_else(|_| "Unknown error".to_string());
466            eprintln!("Server error {status}: {error_text}");
467            return Ok((true, None)); // Retry
468         }
469
470         if !status.is_success() {
471            let error_text = response
472               .text()
473               .unwrap_or_else(|_| "Unknown error".to_string());
474            return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
475         }
476
477         let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
478
479         if api_response.choices.is_empty() {
480            return Err(CommitGenError::Other("Summary creation response was empty".to_string()));
481         }
482
483         let message_choice = &api_response.choices[0].message;
484
485         if !message_choice.tool_calls.is_empty() {
486            let tool_call = &message_choice.tool_calls[0];
487            if tool_call.function.name == "create_commit_summary" {
488               let args = &tool_call.function.arguments;
489               if args.is_empty() {
490                  eprintln!(
491                     "Warning: Model returned empty function arguments for summary. Model may not \
492                      support function calling."
493                  );
494                  return Err(CommitGenError::Other(
495                     "Model returned empty summary arguments - try using a Claude model \
496                      (sonnet/opus/haiku)"
497                        .to_string(),
498                  ));
499               }
500               let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
501                  CommitGenError::Other(format!(
502                     "Failed to parse summary response: {}. Response was: {}",
503                     e,
504                     args.chars().take(200).collect::<String>()
505                  ))
506               })?;
507               return Ok((
508                  false,
509                  Some(CommitSummary::new(summary.summary, config.summary_hard_limit)?),
510               ));
511            }
512         }
513
514         if let Some(content) = &message_choice.content {
515            let summary: SummaryOutput =
516               serde_json::from_str(content.trim()).map_err(CommitGenError::JsonError)?;
517            return Ok((
518               false,
519               Some(CommitSummary::new(summary.summary, config.summary_hard_limit)?),
520            ));
521         }
522
523         Err(CommitGenError::Other("No summary found in summary creation response".to_string()))
524      });
525
526      match result {
527         Ok(summary) => {
528            // Validate quality
529            match validate_summary_quality(summary.as_str(), commit_type, stat) {
530               Ok(()) => return Ok(summary),
531               Err(reason) if validation_attempt < max_validation_retries => {
532                  eprintln!(
533                     "⚠ Validation failed (attempt {}/{}): {}",
534                     validation_attempt + 1,
535                     max_validation_retries + 1,
536                     reason
537                  );
538                  last_failure_reason = Some(reason);
539                  validation_attempt += 1;
540                  // Retry with constraint
541               },
542               Err(reason) => {
543                  eprintln!(
544                     "⚠ Validation failed after {} retries: {}. Using fallback.",
545                     max_validation_retries + 1,
546                     reason
547                  );
548                  // Fallback: use first detail or heuristic
549                  return Ok(fallback_from_details_or_summary(
550                     details,
551                     summary.as_str(),
552                     commit_type,
553                     config,
554                  ));
555               },
556            }
557         },
558         Err(e) => return Err(e),
559      }
560   }
561}
562
563/// Fallback when validation fails: use first detail, strip type word if present
564fn fallback_from_details_or_summary(
565   details: &[String],
566   invalid_summary: &str,
567   commit_type: &str,
568   config: &CommitConfig,
569) -> CommitSummary {
570   let candidate = if let Some(first_detail) = details.first() {
571      // Use first detail line, strip type word
572      let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
573
574      // Remove type word if present at start
575      let type_word_variants =
576         [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
577      for variant in &type_word_variants {
578         if cleaned
579            .to_lowercase()
580            .starts_with(&format!("{} ", variant.to_lowercase()))
581         {
582            cleaned = cleaned[variant.len()..].trim().to_string();
583            break;
584         }
585      }
586
587      cleaned
588   } else {
589      // No details, try to fix invalid summary
590      let mut cleaned = invalid_summary
591         .split_whitespace()
592         .skip(1) // Remove first word (invalid verb)
593         .collect::<Vec<_>>()
594         .join(" ");
595
596      if cleaned.is_empty() {
597         cleaned = fallback_summary("", details, commit_type, config)
598            .as_str()
599            .to_string();
600      }
601
602      cleaned
603   };
604
605   // Ensure valid past-tense verb prefix
606   let with_verb = if candidate
607      .split_whitespace()
608      .next()
609      .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
610   {
611      candidate
612   } else {
613      let verb = match commit_type {
614         "feat" => "added",
615         "fix" => "fixed",
616         "refactor" => "restructured",
617         "docs" => "documented",
618         "test" => "tested",
619         "perf" => "optimized",
620         "build" | "ci" | "chore" => "updated",
621         "style" => "formatted",
622         "revert" => "reverted",
623         _ => "changed",
624      };
625      format!("{verb} {candidate}")
626   };
627
628   CommitSummary::new(with_verb, config.summary_hard_limit)
629      .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
630}
631
632/// Provide a deterministic fallback summary if model generation fails
633pub fn fallback_summary(
634   stat: &str,
635   details: &[String],
636   commit_type: &str,
637   config: &CommitConfig,
638) -> CommitSummary {
639   let mut candidate = if let Some(first) = details.first() {
640      first.trim().trim_end_matches('.').to_string()
641   } else {
642      let primary_line = stat
643         .lines()
644         .map(str::trim)
645         .find(|line| !line.is_empty())
646         .unwrap_or("files");
647
648      let subject = primary_line
649         .split('|')
650         .next()
651         .map(str::trim)
652         .filter(|s| !s.is_empty())
653         .unwrap_or("files");
654
655      if subject.eq_ignore_ascii_case("files") {
656         "Updated files".to_string()
657      } else {
658         format!("Updated {subject}")
659      }
660   };
661
662   candidate = candidate
663      .replace(['\n', '\r'], " ")
664      .split_whitespace()
665      .collect::<Vec<_>>()
666      .join(" ")
667      .trim()
668      .trim_end_matches('.')
669      .trim_end_matches(';')
670      .trim_end_matches(':')
671      .to_string();
672
673   if candidate.is_empty() {
674      candidate = "Updated files".to_string();
675   }
676
677   // Truncate to conservative length (50 chars) since we don't know the scope yet
678   // post_process_commit_message will truncate further if needed
679   const CONSERVATIVE_MAX: usize = 50;
680   while candidate.len() > CONSERVATIVE_MAX {
681      if let Some(pos) = candidate.rfind(' ') {
682         candidate.truncate(pos);
683         candidate = candidate.trim_end_matches(',').trim().to_string();
684      } else {
685         candidate.truncate(CONSERVATIVE_MAX);
686         break;
687      }
688   }
689
690   // Ensure no trailing period (conventional commits style)
691   candidate = candidate.trim_end_matches('.').to_string();
692
693   // If the candidate ended up identical to the commit type, replace with a safer
694   // default
695   if candidate
696      .split_whitespace()
697      .next()
698      .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
699   {
700      candidate = match commit_type {
701         "refactor" => "restructured change".to_string(),
702         "feat" => "added functionality".to_string(),
703         "fix" => "fixed issue".to_string(),
704         "docs" => "documented updates".to_string(),
705         "test" => "tested changes".to_string(),
706         "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
707         "perf" => "optimized performance".to_string(),
708         "revert" => "reverted previous commit".to_string(),
709         _ => "updated files".to_string(),
710      };
711   }
712
713   // Unwrap is safe: fallback_summary guarantees non-empty string ≤50 chars (<
714   // config limit)
715   CommitSummary::new(candidate, config.summary_hard_limit)
716      .expect("fallback summary should always be valid")
717}
718
719#[cfg(test)]
720mod tests {
721   use super::*;
722   use crate::config::CommitConfig;
723
724   #[test]
725   fn test_validate_summary_quality_valid() {
726      let stat = "src/main.rs | 10 +++++++---\n";
727      assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
728      assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
729      assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
730   }
731
732   #[test]
733   fn test_validate_summary_quality_invalid_verb() {
734      let stat = "src/main.rs | 10 +++++++---\n";
735      let result = validate_summary_quality("adding new feature", "feat", stat);
736      assert!(result.is_err());
737      assert!(result.unwrap_err().contains("past-tense verb"));
738   }
739
740   #[test]
741   fn test_validate_summary_quality_type_repetition() {
742      let stat = "src/main.rs | 10 +++++++---\n";
743      // "feat" is not a past-tense verb so it should fail on verb check first
744      let result = validate_summary_quality("feat new feature", "feat", stat);
745      assert!(result.is_err());
746      assert!(result.unwrap_err().contains("past-tense verb"));
747
748      // "fixed" is past-tense but repeats "fix" type
749      let result = validate_summary_quality("fix bug", "fix", stat);
750      assert!(result.is_err());
751      // "fix" is not in PAST_TENSE_VERBS, so fails on verb check
752      assert!(result.unwrap_err().contains("past-tense verb"));
753   }
754
755   #[test]
756   fn test_validate_summary_quality_empty() {
757      let stat = "src/main.rs | 10 +++++++---\n";
758      let result = validate_summary_quality("", "feat", stat);
759      assert!(result.is_err());
760      assert!(result.unwrap_err().contains("empty"));
761   }
762
763   #[test]
764   fn test_validate_summary_quality_markdown_type_mismatch() {
765      let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
766      // Should warn but not fail
767      assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
768   }
769
770   #[test]
771   fn test_validate_summary_quality_no_code_files() {
772      let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
773      // Should warn but not fail
774      assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
775   }
776
777   #[test]
778   fn test_fallback_from_details_with_first_detail() {
779      let config = CommitConfig::default();
780      let details = vec![
781         "Added authentication middleware.".to_string(),
782         "Updated error handling.".to_string(),
783      ];
784      let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
785      // Capital A preserved from detail
786      assert_eq!(result.as_str(), "Added authentication middleware");
787   }
788
789   #[test]
790   fn test_fallback_from_details_strips_type_word() {
791      let config = CommitConfig::default();
792      let details = vec!["Featuring new oauth flow.".to_string()];
793      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
794      // Should strip "Featuring" (present participle, not past tense) and add valid
795      // verb
796      assert!(result.as_str().starts_with("added"));
797   }
798
799   #[test]
800   fn test_fallback_from_details_no_details() {
801      let config = CommitConfig::default();
802      let details: Vec<String> = vec![];
803      let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
804      // Should use rest of summary or fallback
805      assert!(result.as_str().starts_with("added"));
806   }
807
808   #[test]
809   fn test_fallback_from_details_adds_verb() {
810      let config = CommitConfig::default();
811      let details = vec!["configuration for oauth".to_string()];
812      let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
813      assert_eq!(result.as_str(), "added configuration for oauth");
814   }
815
816   #[test]
817   fn test_fallback_from_details_preserves_existing_verb() {
818      let config = CommitConfig::default();
819      let details = vec!["fixed authentication bug".to_string()];
820      let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
821      assert_eq!(result.as_str(), "fixed authentication bug");
822   }
823
824   #[test]
825   fn test_fallback_from_details_type_specific_verbs() {
826      let config = CommitConfig::default();
827      let details = vec!["module structure".to_string()];
828
829      let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
830      assert_eq!(result.as_str(), "restructured module structure");
831
832      let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
833      assert_eq!(result.as_str(), "documented module structure");
834
835      let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
836      assert_eq!(result.as_str(), "tested module structure");
837
838      let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
839      assert_eq!(result.as_str(), "optimized module structure");
840   }
841
842   #[test]
843   fn test_fallback_summary_with_stat() {
844      let config = CommitConfig::default();
845      let stat = "src/main.rs | 10 +++++++---\n";
846      let details = vec![];
847      let result = fallback_summary(stat, &details, "feat", &config);
848      assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
849   }
850
851   #[test]
852   fn test_fallback_summary_with_details() {
853      let config = CommitConfig::default();
854      let stat = "";
855      let details = vec!["First detail here.".to_string()];
856      let result = fallback_summary(stat, &details, "feat", &config);
857      // Capital F preserved
858      assert_eq!(result.as_str(), "First detail here");
859   }
860
861   #[test]
862   fn test_fallback_summary_no_stat_no_details() {
863      let config = CommitConfig::default();
864      let result = fallback_summary("", &[], "feat", &config);
865      // Fallback returns "Updated files" when no stat/details
866      assert_eq!(result.as_str(), "Updated files");
867   }
868
869   #[test]
870   fn test_fallback_summary_type_word_overlap() {
871      let config = CommitConfig::default();
872      let details = vec!["refactor was performed".to_string()];
873      let result = fallback_summary("", &details, "refactor", &config);
874      // Should replace "refactor" with type-specific verb
875      assert_eq!(result.as_str(), "restructured change");
876   }
877
878   #[test]
879   fn test_fallback_summary_length_limit() {
880      let config = CommitConfig::default();
881      let long_detail = "a ".repeat(100); // 200 chars
882      let details = vec![long_detail.trim().to_string()];
883      let result = fallback_summary("", &details, "feat", &config);
884      // Should truncate to conservative max (50 chars)
885      assert!(result.len() <= 50);
886   }
887}