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 = 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 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)); }
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 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 },
542 Err(reason) => {
543 eprintln!(
544 "⚠ Validation failed after {} retries: {}. Using fallback.",
545 max_validation_retries + 1,
546 reason
547 );
548 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
563fn 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 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
573
574 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 let mut cleaned = invalid_summary
591 .split_whitespace()
592 .skip(1) .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 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
632pub 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 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 candidate = candidate.trim_end_matches('.').to_string();
692
693 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 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 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 let result = validate_summary_quality("fix bug", "fix", stat);
750 assert!(result.is_err());
751 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 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 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 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 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 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 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 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 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); let details = vec![long_detail.trim().to_string()];
883 let result = fallback_summary("", &details, "feat", &config);
884 assert!(result.len() <= 50);
886 }
887}