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
12fn 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
96pub 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
138pub 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 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 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 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)); }
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 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 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
298fn 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 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 if first_word_lower == commit_type {
322 return Err(format!("repeats commit type '{commit_type}' in summary"));
323 }
324
325 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 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 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
361pub 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 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 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 }; 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 if let Some(ref api_key) = config.api_key {
451 request_builder =
452 request_builder.header("Authorization", format!("Bearer {}", api_key));
453 }
454
455 let response = request_builder
456 .json(&request)
457 .send()
458 .map_err(CommitGenError::HttpError)?;
459
460 let status = response.status();
461
462 if status.is_server_error() {
464 let error_text = response
465 .text()
466 .unwrap_or_else(|_| "Unknown error".to_string());
467 eprintln!("Server error {status}: {error_text}");
468 return Ok((true, None)); }
470
471 if !status.is_success() {
472 let error_text = response
473 .text()
474 .unwrap_or_else(|_| "Unknown error".to_string());
475 return Err(CommitGenError::ApiError { status: status.as_u16(), body: error_text });
476 }
477
478 let api_response: ApiResponse = response.json().map_err(CommitGenError::HttpError)?;
479
480 if api_response.choices.is_empty() {
481 return Err(CommitGenError::Other("Summary creation response was empty".to_string()));
482 }
483
484 let message_choice = &api_response.choices[0].message;
485
486 if !message_choice.tool_calls.is_empty() {
487 let tool_call = &message_choice.tool_calls[0];
488 if tool_call.function.name == "create_commit_summary" {
489 let args = &tool_call.function.arguments;
490 if args.is_empty() {
491 eprintln!(
492 "Warning: Model returned empty function arguments for summary. Model may not \
493 support function calling."
494 );
495 return Err(CommitGenError::Other(
496 "Model returned empty summary arguments - try using a Claude model \
497 (sonnet/opus/haiku)"
498 .to_string(),
499 ));
500 }
501 let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
502 CommitGenError::Other(format!(
503 "Failed to parse summary response: {}. Response was: {}",
504 e,
505 args.chars().take(200).collect::<String>()
506 ))
507 })?;
508 return Ok((
509 false,
510 Some(CommitSummary::new(summary.summary, config.summary_hard_limit)?),
511 ));
512 }
513 }
514
515 if let Some(content) = &message_choice.content {
516 let summary: SummaryOutput =
517 serde_json::from_str(content.trim()).map_err(CommitGenError::JsonError)?;
518 return Ok((
519 false,
520 Some(CommitSummary::new(summary.summary, config.summary_hard_limit)?),
521 ));
522 }
523
524 Err(CommitGenError::Other("No summary found in summary creation response".to_string()))
525 });
526
527 match result {
528 Ok(summary) => {
529 match validate_summary_quality(summary.as_str(), commit_type, stat) {
531 Ok(()) => return Ok(summary),
532 Err(reason) if validation_attempt < max_validation_retries => {
533 eprintln!(
534 "⚠ Validation failed (attempt {}/{}): {}",
535 validation_attempt + 1,
536 max_validation_retries + 1,
537 reason
538 );
539 last_failure_reason = Some(reason);
540 validation_attempt += 1;
541 },
543 Err(reason) => {
544 eprintln!(
545 "⚠ Validation failed after {} retries: {}. Using fallback.",
546 max_validation_retries + 1,
547 reason
548 );
549 return Ok(fallback_from_details_or_summary(
551 details,
552 summary.as_str(),
553 commit_type,
554 config,
555 ));
556 },
557 }
558 },
559 Err(e) => return Err(e),
560 }
561 }
562}
563
564fn fallback_from_details_or_summary(
566 details: &[String],
567 invalid_summary: &str,
568 commit_type: &str,
569 config: &CommitConfig,
570) -> CommitSummary {
571 let candidate = if let Some(first_detail) = details.first() {
572 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
574
575 let type_word_variants =
577 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
578 for variant in &type_word_variants {
579 if cleaned
580 .to_lowercase()
581 .starts_with(&format!("{} ", variant.to_lowercase()))
582 {
583 cleaned = cleaned[variant.len()..].trim().to_string();
584 break;
585 }
586 }
587
588 cleaned
589 } else {
590 let mut cleaned = invalid_summary
592 .split_whitespace()
593 .skip(1) .collect::<Vec<_>>()
595 .join(" ");
596
597 if cleaned.is_empty() {
598 cleaned = fallback_summary("", details, commit_type, config)
599 .as_str()
600 .to_string();
601 }
602
603 cleaned
604 };
605
606 let with_verb = if candidate
608 .split_whitespace()
609 .next()
610 .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
611 {
612 candidate
613 } else {
614 let verb = match commit_type {
615 "feat" => "added",
616 "fix" => "fixed",
617 "refactor" => "restructured",
618 "docs" => "documented",
619 "test" => "tested",
620 "perf" => "optimized",
621 "build" | "ci" | "chore" => "updated",
622 "style" => "formatted",
623 "revert" => "reverted",
624 _ => "changed",
625 };
626 format!("{verb} {candidate}")
627 };
628
629 CommitSummary::new(with_verb, config.summary_hard_limit)
630 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
631}
632
633pub fn fallback_summary(
635 stat: &str,
636 details: &[String],
637 commit_type: &str,
638 config: &CommitConfig,
639) -> CommitSummary {
640 let mut candidate = if let Some(first) = details.first() {
641 first.trim().trim_end_matches('.').to_string()
642 } else {
643 let primary_line = stat
644 .lines()
645 .map(str::trim)
646 .find(|line| !line.is_empty())
647 .unwrap_or("files");
648
649 let subject = primary_line
650 .split('|')
651 .next()
652 .map(str::trim)
653 .filter(|s| !s.is_empty())
654 .unwrap_or("files");
655
656 if subject.eq_ignore_ascii_case("files") {
657 "Updated files".to_string()
658 } else {
659 format!("Updated {subject}")
660 }
661 };
662
663 candidate = candidate
664 .replace(['\n', '\r'], " ")
665 .split_whitespace()
666 .collect::<Vec<_>>()
667 .join(" ")
668 .trim()
669 .trim_end_matches('.')
670 .trim_end_matches(';')
671 .trim_end_matches(':')
672 .to_string();
673
674 if candidate.is_empty() {
675 candidate = "Updated files".to_string();
676 }
677
678 const CONSERVATIVE_MAX: usize = 50;
681 while candidate.len() > CONSERVATIVE_MAX {
682 if let Some(pos) = candidate.rfind(' ') {
683 candidate.truncate(pos);
684 candidate = candidate.trim_end_matches(',').trim().to_string();
685 } else {
686 candidate.truncate(CONSERVATIVE_MAX);
687 break;
688 }
689 }
690
691 candidate = candidate.trim_end_matches('.').to_string();
693
694 if candidate
697 .split_whitespace()
698 .next()
699 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
700 {
701 candidate = match commit_type {
702 "refactor" => "restructured change".to_string(),
703 "feat" => "added functionality".to_string(),
704 "fix" => "fixed issue".to_string(),
705 "docs" => "documented updates".to_string(),
706 "test" => "tested changes".to_string(),
707 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
708 "perf" => "optimized performance".to_string(),
709 "revert" => "reverted previous commit".to_string(),
710 _ => "updated files".to_string(),
711 };
712 }
713
714 CommitSummary::new(candidate, config.summary_hard_limit)
717 .expect("fallback summary should always be valid")
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use crate::config::CommitConfig;
724
725 #[test]
726 fn test_validate_summary_quality_valid() {
727 let stat = "src/main.rs | 10 +++++++---\n";
728 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
729 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
730 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
731 }
732
733 #[test]
734 fn test_validate_summary_quality_invalid_verb() {
735 let stat = "src/main.rs | 10 +++++++---\n";
736 let result = validate_summary_quality("adding new feature", "feat", stat);
737 assert!(result.is_err());
738 assert!(result.unwrap_err().contains("past-tense verb"));
739 }
740
741 #[test]
742 fn test_validate_summary_quality_type_repetition() {
743 let stat = "src/main.rs | 10 +++++++---\n";
744 let result = validate_summary_quality("feat new feature", "feat", stat);
746 assert!(result.is_err());
747 assert!(result.unwrap_err().contains("past-tense verb"));
748
749 let result = validate_summary_quality("fix bug", "fix", stat);
751 assert!(result.is_err());
752 assert!(result.unwrap_err().contains("past-tense verb"));
754 }
755
756 #[test]
757 fn test_validate_summary_quality_empty() {
758 let stat = "src/main.rs | 10 +++++++---\n";
759 let result = validate_summary_quality("", "feat", stat);
760 assert!(result.is_err());
761 assert!(result.unwrap_err().contains("empty"));
762 }
763
764 #[test]
765 fn test_validate_summary_quality_markdown_type_mismatch() {
766 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
767 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
769 }
770
771 #[test]
772 fn test_validate_summary_quality_no_code_files() {
773 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
774 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
776 }
777
778 #[test]
779 fn test_fallback_from_details_with_first_detail() {
780 let config = CommitConfig::default();
781 let details = vec![
782 "Added authentication middleware.".to_string(),
783 "Updated error handling.".to_string(),
784 ];
785 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
786 assert_eq!(result.as_str(), "Added authentication middleware");
788 }
789
790 #[test]
791 fn test_fallback_from_details_strips_type_word() {
792 let config = CommitConfig::default();
793 let details = vec!["Featuring new oauth flow.".to_string()];
794 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
795 assert!(result.as_str().starts_with("added"));
798 }
799
800 #[test]
801 fn test_fallback_from_details_no_details() {
802 let config = CommitConfig::default();
803 let details: Vec<String> = vec![];
804 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
805 assert!(result.as_str().starts_with("added"));
807 }
808
809 #[test]
810 fn test_fallback_from_details_adds_verb() {
811 let config = CommitConfig::default();
812 let details = vec!["configuration for oauth".to_string()];
813 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
814 assert_eq!(result.as_str(), "added configuration for oauth");
815 }
816
817 #[test]
818 fn test_fallback_from_details_preserves_existing_verb() {
819 let config = CommitConfig::default();
820 let details = vec!["fixed authentication bug".to_string()];
821 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
822 assert_eq!(result.as_str(), "fixed authentication bug");
823 }
824
825 #[test]
826 fn test_fallback_from_details_type_specific_verbs() {
827 let config = CommitConfig::default();
828 let details = vec!["module structure".to_string()];
829
830 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
831 assert_eq!(result.as_str(), "restructured module structure");
832
833 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
834 assert_eq!(result.as_str(), "documented module structure");
835
836 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
837 assert_eq!(result.as_str(), "tested module structure");
838
839 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
840 assert_eq!(result.as_str(), "optimized module structure");
841 }
842
843 #[test]
844 fn test_fallback_summary_with_stat() {
845 let config = CommitConfig::default();
846 let stat = "src/main.rs | 10 +++++++---\n";
847 let details = vec![];
848 let result = fallback_summary(stat, &details, "feat", &config);
849 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
850 }
851
852 #[test]
853 fn test_fallback_summary_with_details() {
854 let config = CommitConfig::default();
855 let stat = "";
856 let details = vec!["First detail here.".to_string()];
857 let result = fallback_summary(stat, &details, "feat", &config);
858 assert_eq!(result.as_str(), "First detail here");
860 }
861
862 #[test]
863 fn test_fallback_summary_no_stat_no_details() {
864 let config = CommitConfig::default();
865 let result = fallback_summary("", &[], "feat", &config);
866 assert_eq!(result.as_str(), "Updated files");
868 }
869
870 #[test]
871 fn test_fallback_summary_type_word_overlap() {
872 let config = CommitConfig::default();
873 let details = vec!["refactor was performed".to_string()];
874 let result = fallback_summary("", &details, "refactor", &config);
875 assert_eq!(result.as_str(), "restructured change");
877 }
878
879 #[test]
880 fn test_fallback_summary_length_limit() {
881 let config = CommitConfig::default();
882 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
884 let result = fallback_summary("", &details, "feat", &config);
885 assert!(result.len() <= 50);
887 }
888}