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#[derive(Default)]
17pub struct AnalysisContext<'a> {
18 pub user_context: Option<&'a str>,
20 pub recent_commits: Option<&'a str>,
22 pub common_scopes: Option<&'a str>,
24 pub project_context: Option<&'a str>,
26}
27
28fn 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
110pub 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
161pub 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
191pub 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 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
205
206 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 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 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)); }
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 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 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
378fn 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 let prefix_no_scope = format!("{commit_type}: ");
391 summary.strip_prefix(&prefix_no_scope)
392 })
393 .unwrap_or(summary)
394 .to_string()
395}
396
397fn 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 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 if first_word_lower == commit_type {
421 return Err(format!("repeats commit type '{commit_type}' in summary"));
422 }
423
424 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 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 let code_exts = [
448 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
450 "java", "kt", "kts", "scala", "groovy", "clj", "cljs", "cs", "fs", "vb", "js", "ts", "jsx", "tsx", "mjs", "cjs", "vue", "svelte", "py", "pyx", "pxd", "pyi", "rb", "rake", "gemspec", "php", "go", "swift", "m", "mm", "lua", "sh", "bash", "zsh", "fish", "pl", "pm", "hs", "lhs", "ml", "mli", "fs", "fsi", "elm", "ex", "exs", "erl", "hrl",
463 "lisp", "cl", "el", "scm", "rkt", "jl", "r", "R", "dart", "cr", "d", "f", "f90", "f95", "f03", "f08", "ada", "adb", "ads", "cob", "cbl", "asm", "s", "S", "sql", "plsql", "pl", "pro", "re", "rei", "nix", "tf", "hcl", "sol", "move", "cairo",
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
497pub 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 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 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 }; 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 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 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)); }
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 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 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 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 },
677 Err(reason) => {
678 crate::style::warn(&format!(
679 "Validation failed after {} retries: {}. Using fallback.",
680 max_validation_retries + 1,
681 reason
682 ));
683 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
698fn 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 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
708
709 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 let mut cleaned = invalid_summary
726 .split_whitespace()
727 .skip(1) .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 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
767pub 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 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 candidate = candidate.trim_end_matches('.').to_string();
827
828 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 CommitSummary::new(candidate, config.summary_hard_limit)
851 .expect("fallback summary should always be valid")
852}
853
854pub 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 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 let result = validate_summary_quality("fix bug", "fix", stat);
911 assert!(result.is_err());
912 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 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 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 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 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 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 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 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 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); let details = vec![long_detail.trim().to_string()];
1044 let result = fallback_summary("", &details, "feat", &config);
1045 assert!(result.len() <= 50);
1047 }
1048}