1use std::{path::Path, sync::OnceLock, time::Duration};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{
6 config::{CommitConfig, ResolvedApiMode},
7 error::{CommitGenError, Result},
8 templates,
9 tokens::TokenCounter,
10 types::{CommitSummary, ConventionalAnalysis},
11};
12
13static TRACE_ENABLED: OnceLock<bool> = OnceLock::new();
15
16fn trace_enabled() -> bool {
18 *TRACE_ENABLED.get_or_init(|| std::env::var("LLM_GIT_TRACE").is_ok())
19}
20
21pub async fn timed_send(
26 request_builder: reqwest::RequestBuilder,
27 label: &str,
28 model: &str,
29) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
30 let trace = trace_enabled();
31 let start = std::time::Instant::now();
32
33 let response = request_builder
34 .send()
35 .await
36 .map_err(CommitGenError::HttpError)?;
37
38 let ttft = start.elapsed();
39 let status = response.status();
40 let content_length = response.content_length();
41
42 let body = response.text().await.map_err(CommitGenError::HttpError)?;
43 let total = start.elapsed();
44
45 if trace {
46 let size_info = content_length.map_or_else(
47 || format!("{}B", body.len()),
48 |cl| format!("{}B (content-length: {cl})", body.len()),
49 );
50 if !crate::style::pipe_mode() {
52 print!("\r\x1b[K");
53 std::io::Write::flush(&mut std::io::stdout()).ok();
54 }
55 eprintln!(
56 "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
57 body={size_info}"
58 );
59 }
60
61 Ok((status, body))
62}
63
64#[derive(Default)]
68pub struct AnalysisContext<'a> {
69 pub user_context: Option<&'a str>,
71 pub recent_commits: Option<&'a str>,
73 pub common_scopes: Option<&'a str>,
75 pub project_context: Option<&'a str>,
77 pub debug_output: Option<&'a Path>,
79 pub debug_prefix: Option<&'a str>,
81}
82
83static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
85
86pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
91 CLIENT.get_or_init(|| {
92 reqwest::Client::builder()
93 .timeout(Duration::from_secs(config.request_timeout_secs))
94 .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
95 .build()
96 .expect("Failed to build HTTP client")
97 })
98}
99
100fn debug_filename(prefix: Option<&str>, name: &str) -> String {
101 match prefix {
102 Some(p) if !p.is_empty() => format!("{p}_{name}"),
103 _ => name.to_string(),
104 }
105}
106
107fn response_snippet(body: &str, limit: usize) -> String {
108 if body.is_empty() {
109 return "<empty response body>".to_string();
110 }
111 let mut snippet = body.trim().to_string();
112 if snippet.len() > limit {
113 snippet.truncate(limit);
114 snippet.push_str("...");
115 }
116 snippet
117}
118
119fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
120 let Some(dir) = debug_dir else {
121 return Ok(());
122 };
123
124 std::fs::create_dir_all(dir)?;
125 let path = dir.join(filename);
126 std::fs::write(&path, content)?;
127 Ok(())
128}
129
130fn anthropic_messages_url(base_url: &str) -> String {
131 let trimmed = base_url.trim_end_matches('/');
132 if trimmed.ends_with("/v1") {
133 format!("{trimmed}/messages")
134 } else {
135 format!("{trimmed}/v1/messages")
136 }
137}
138
139fn extract_anthropic_content(
140 response_text: &str,
141 tool_name: &str,
142) -> Result<(Option<serde_json::Value>, String)> {
143 let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
144 CommitGenError::Other(format!(
145 "Failed to parse Anthropic response JSON: {e}. Response body: {}",
146 response_snippet(response_text, 500)
147 ))
148 })?;
149
150 let mut tool_input: Option<serde_json::Value> = None;
151 let mut text_parts = Vec::new();
152
153 if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
154 for item in content {
155 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
156 match item_type {
157 "tool_use" => {
158 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
159 if name == tool_name
160 && let Some(input) = item.get("input")
161 {
162 tool_input = Some(input.clone());
163 }
164 },
165 "text" => {
166 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
167 text_parts.push(text.to_string());
168 }
169 },
170 _ => {},
171 }
172 }
173 }
174
175 Ok((tool_input, text_parts.join("\n")))
176}
177
178#[derive(Debug, Serialize)]
179struct Message {
180 role: String,
181 content: String,
182}
183
184#[derive(Debug, Serialize, Deserialize)]
185struct FunctionParameters {
186 #[serde(rename = "type")]
187 param_type: String,
188 properties: serde_json::Value,
189 required: Vec<String>,
190}
191
192#[derive(Debug, Serialize, Deserialize)]
193struct Function {
194 name: String,
195 description: String,
196 parameters: FunctionParameters,
197}
198
199#[derive(Debug, Serialize, Deserialize)]
200struct Tool {
201 #[serde(rename = "type")]
202 tool_type: String,
203 function: Function,
204}
205
206#[derive(Debug, Serialize)]
207struct ApiRequest {
208 model: String,
209 max_tokens: u32,
210 temperature: f32,
211 tools: Vec<Tool>,
212 #[serde(skip_serializing_if = "Option::is_none")]
213 tool_choice: Option<serde_json::Value>,
214 messages: Vec<Message>,
215}
216
217#[derive(Debug, Serialize)]
218struct AnthropicRequest {
219 model: String,
220 max_tokens: u32,
221 temperature: f32,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 system: Option<String>,
224 tools: Vec<AnthropicTool>,
225 #[serde(skip_serializing_if = "Option::is_none")]
226 tool_choice: Option<AnthropicToolChoice>,
227 messages: Vec<AnthropicMessage>,
228}
229
230#[derive(Debug, Serialize)]
231struct AnthropicTool {
232 name: String,
233 description: String,
234 input_schema: serde_json::Value,
235}
236
237#[derive(Debug, Serialize)]
238struct AnthropicToolChoice {
239 #[serde(rename = "type")]
240 choice_type: String,
241 name: String,
242}
243
244#[derive(Debug, Serialize)]
245struct AnthropicMessage {
246 role: String,
247 content: Vec<AnthropicContent>,
248}
249
250#[derive(Debug, Serialize)]
251struct AnthropicContent {
252 #[serde(rename = "type")]
253 content_type: String,
254 text: String,
255}
256
257#[derive(Debug, Deserialize)]
258struct ToolCall {
259 function: FunctionCall,
260}
261
262#[derive(Debug, Deserialize)]
263struct FunctionCall {
264 name: String,
265 arguments: String,
266}
267
268#[derive(Debug, Deserialize)]
269struct Choice {
270 message: ResponseMessage,
271}
272
273#[derive(Debug, Deserialize)]
274struct ResponseMessage {
275 #[serde(default)]
276 tool_calls: Vec<ToolCall>,
277 #[serde(default)]
278 content: Option<String>,
279}
280
281#[derive(Debug, Deserialize)]
282struct ApiResponse {
283 choices: Vec<Choice>,
284}
285
286#[derive(Debug, Clone, Serialize, Deserialize)]
287struct SummaryOutput {
288 summary: String,
289}
290
291pub async fn retry_api_call<T>(
293 config: &CommitConfig,
294 mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
295) -> Result<T> {
296 let mut attempt = 0;
297
298 loop {
299 attempt += 1;
300
301 match f().await {
302 Ok((false, Some(result))) => return Ok(result),
303 Ok((false, None)) => {
304 return Err(CommitGenError::Other("API call failed without result".to_string()));
305 },
306 Ok((true, _)) if attempt < config.max_retries => {
307 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
308 eprintln!(
309 "{}",
310 crate::style::warning(&format!(
311 "Retry {}/{} after {}ms...",
312 attempt, config.max_retries, backoff_ms
313 ))
314 );
315 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
316 },
317 Ok((true, _last_err)) => {
318 return Err(CommitGenError::ApiRetryExhausted {
319 retries: config.max_retries,
320 source: Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
321 });
322 },
323 Err(e) => {
324 if attempt < config.max_retries {
325 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
326 eprintln!(
327 "{}",
328 crate::style::warning(&format!(
329 "Error: {} - Retry {}/{} after {}ms...",
330 e, attempt, config.max_retries, backoff_ms
331 ))
332 );
333 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
334 continue;
335 }
336 return Err(e);
337 },
338 }
339 }
340}
341
342pub fn format_types_description(config: &CommitConfig) -> String {
345 use std::fmt::Write;
346 let mut out = String::from("Check types in order (first match wins):\n\n");
347
348 for (name, tc) in &config.types {
349 let _ = writeln!(out, "**{name}**: {}", tc.description);
350 if !tc.diff_indicators.is_empty() {
351 let _ = writeln!(out, " Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
352 }
353 if !tc.file_patterns.is_empty() {
354 let _ = writeln!(out, " File patterns: {}", tc.file_patterns.join(", "));
355 }
356 for ex in &tc.examples {
357 let _ = writeln!(out, " - {ex}");
358 }
359 if !tc.hint.is_empty() {
360 let _ = writeln!(out, " Note: {}", tc.hint);
361 }
362 out.push('\n');
363 }
364
365 if !config.classifier_hint.is_empty() {
366 let _ = writeln!(out, "\n{}", config.classifier_hint);
367 }
368
369 out
370}
371
372pub async fn generate_conventional_analysis<'a>(
374 stat: &'a str,
375 diff: &'a str,
376 model_name: &'a str,
377 scope_candidates_str: &'a str,
378 ctx: &AnalysisContext<'a>,
379 config: &'a CommitConfig,
380) -> Result<ConventionalAnalysis> {
381 retry_api_call(config, async move || {
382 let client = get_client(config);
383
384 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
386
387 let tool = Tool {
389 tool_type: "function".to_string(),
390 function: Function {
391 name: "create_conventional_analysis".to_string(),
392 description: "Analyze changes and classify as conventional commit with type, scope, \
393 details, and metadata"
394 .to_string(),
395 parameters: FunctionParameters {
396 param_type: "object".to_string(),
397 properties: serde_json::json!({
398 "type": {
399 "type": "string",
400 "enum": type_enum,
401 "description": "Commit type based on change classification"
402 },
403 "scope": {
404 "type": "string",
405 "description": "Optional scope (module/component). Omit if unclear or multi-component."
406 },
407 "details": {
408 "type": "array",
409 "description": "Array of 0-6 detail items with changelog metadata.",
410 "items": {
411 "type": "object",
412 "properties": {
413 "text": {
414 "type": "string",
415 "description": "Detail about change, starting with past-tense verb, ending with period"
416 },
417 "changelog_category": {
418 "type": "string",
419 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
420 "description": "Changelog category if user-visible. Omit for internal changes."
421 },
422 "user_visible": {
423 "type": "boolean",
424 "description": "True if this change affects users/API and should appear in changelog"
425 }
426 },
427 "required": ["text", "user_visible"]
428 }
429 },
430 "issue_refs": {
431 "type": "array",
432 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
433 "items": {
434 "type": "string"
435 }
436 }
437 }),
438 required: vec![
439 "type".to_string(),
440 "details".to_string(),
441 "issue_refs".to_string(),
442 ],
443 },
444 },
445 };
446
447 let debug_dir = ctx.debug_output;
448 let debug_prefix = ctx.debug_prefix;
449 let mode = config.resolved_api_mode(model_name);
450
451 let response_text = match mode {
452 ResolvedApiMode::ChatCompletions => {
453 let types_desc = format_types_description(config);
454 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
455 variant: &config.analysis_prompt_variant,
456 stat,
457 diff,
458 scope_candidates: scope_candidates_str,
459 recent_commits: ctx.recent_commits,
460 common_scopes: ctx.common_scopes,
461 types_description: Some(&types_desc),
462 project_context: ctx.project_context,
463 })?;
464
465 let user_content = if let Some(user_ctx) = ctx.user_context {
466 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
467 } else {
468 parts.user
469 };
470
471 let request = ApiRequest {
472 model: model_name.to_string(),
473 max_tokens: 1000,
474 temperature: config.temperature,
475 tools: vec![tool],
476 tool_choice: Some(
477 serde_json::json!({ "type": "function", "function": { "name": "create_conventional_analysis" } }),
478 ),
479 messages: vec![
480 Message { role: "system".to_string(), content: parts.system },
481 Message { role: "user".to_string(), content: user_content },
482 ],
483 };
484
485 if debug_dir.is_some() {
486 let request_json = serde_json::to_string_pretty(&request)?;
487 save_debug_output(
488 debug_dir,
489 &debug_filename(debug_prefix, "analysis_request.json"),
490 &request_json,
491 )?;
492 }
493
494 let mut request_builder = client
495 .post(format!("{}/chat/completions", config.api_base_url))
496 .header("content-type", "application/json");
497
498 if let Some(api_key) = &config.api_key {
500 request_builder =
501 request_builder.header("Authorization", format!("Bearer {api_key}"));
502 }
503
504 let (status, response_text) =
505 timed_send(request_builder.json(&request), "analysis", model_name).await?;
506 if debug_dir.is_some() {
507 save_debug_output(
508 debug_dir,
509 &debug_filename(debug_prefix, "analysis_response.json"),
510 &response_text,
511 )?;
512 }
513
514 if status.is_server_error() {
516 eprintln!(
517 "{}",
518 crate::style::error(&format!("Server error {status}: {response_text}"))
519 );
520 return Ok((true, None)); }
522
523 if !status.is_success() {
524 return Err(CommitGenError::ApiError {
525 status: status.as_u16(),
526 body: response_text,
527 });
528 }
529
530 response_text
531 },
532 ResolvedApiMode::AnthropicMessages => {
533 let types_desc = format_types_description(config);
534 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
535 variant: &config.analysis_prompt_variant,
536 stat,
537 diff,
538 scope_candidates: scope_candidates_str,
539 recent_commits: ctx.recent_commits,
540 common_scopes: ctx.common_scopes,
541 types_description: Some(&types_desc),
542 project_context: ctx.project_context,
543 })?;
544
545 let user_content = if let Some(user_ctx) = ctx.user_context {
546 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
547 } else {
548 parts.user
549 };
550
551 let request = AnthropicRequest {
552 model: model_name.to_string(),
553 max_tokens: 1000,
554 temperature: config.temperature,
555 system: Some(parts.system).filter(|s| !s.is_empty()),
556 tools: vec![AnthropicTool {
557 name: "create_conventional_analysis".to_string(),
558 description: "Analyze changes and classify as conventional commit with type, \
559 scope, details, and metadata"
560 .to_string(),
561 input_schema: serde_json::json!({
562 "type": "object",
563 "properties": {
564 "type": {
565 "type": "string",
566 "enum": type_enum,
567 "description": "Commit type based on change classification"
568 },
569 "scope": {
570 "type": "string",
571 "description": "Optional scope (module/component). Omit if unclear or multi-component."
572 },
573 "details": {
574 "type": "array",
575 "description": "Array of 0-6 detail items with changelog metadata.",
576 "items": {
577 "type": "object",
578 "properties": {
579 "text": {
580 "type": "string",
581 "description": "Detail about change, starting with past-tense verb, ending with period"
582 },
583 "changelog_category": {
584 "type": "string",
585 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
586 "description": "Changelog category if user-visible. Omit for internal changes."
587 },
588 "user_visible": {
589 "type": "boolean",
590 "description": "True if this change affects users/API and should appear in changelog"
591 }
592 },
593 "required": ["text", "user_visible"]
594 }
595 },
596 "issue_refs": {
597 "type": "array",
598 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
599 "items": {
600 "type": "string"
601 }
602 }
603 },
604 "required": ["type", "details", "issue_refs"]
605 }),
606 }],
607 tool_choice: Some(AnthropicToolChoice {
608 choice_type: "tool".to_string(),
609 name: "create_conventional_analysis".to_string(),
610 }),
611 messages: vec![AnthropicMessage {
612 role: "user".to_string(),
613 content: vec![AnthropicContent {
614 content_type: "text".to_string(),
615 text: user_content,
616 }],
617 }],
618 };
619
620 if debug_dir.is_some() {
621 let request_json = serde_json::to_string_pretty(&request)?;
622 save_debug_output(
623 debug_dir,
624 &debug_filename(debug_prefix, "analysis_request.json"),
625 &request_json,
626 )?;
627 }
628
629 let mut request_builder = client
630 .post(anthropic_messages_url(&config.api_base_url))
631 .header("content-type", "application/json")
632 .header("anthropic-version", "2023-06-01");
633
634 if let Some(api_key) = &config.api_key {
635 request_builder = request_builder.header("x-api-key", api_key);
636 }
637
638 let (status, response_text) =
639 timed_send(request_builder.json(&request), "analysis", model_name).await?;
640 if debug_dir.is_some() {
641 save_debug_output(
642 debug_dir,
643 &debug_filename(debug_prefix, "analysis_response.json"),
644 &response_text,
645 )?;
646 }
647
648 if status.is_server_error() {
649 eprintln!(
650 "{}",
651 crate::style::error(&format!("Server error {status}: {response_text}"))
652 );
653 return Ok((true, None));
654 }
655
656 if !status.is_success() {
657 return Err(CommitGenError::ApiError {
658 status: status.as_u16(),
659 body: response_text,
660 });
661 }
662
663 response_text
664 },
665 };
666
667 if response_text.trim().is_empty() {
668 crate::style::warn("Model returned empty response body for analysis; retrying.");
669 return Ok((true, None));
670 }
671
672 match mode {
673 ResolvedApiMode::ChatCompletions => {
674 let api_response: ApiResponse = serde_json::from_str(&response_text).map_err(|e| {
675 CommitGenError::Other(format!(
676 "Failed to parse analysis response JSON: {e}. Response body: {}",
677 response_snippet(&response_text, 500)
678 ))
679 })?;
680
681 if api_response.choices.is_empty() {
682 return Err(CommitGenError::Other(
683 "API returned empty response for change analysis".to_string(),
684 ));
685 }
686
687 let message = &api_response.choices[0].message;
688
689 if !message.tool_calls.is_empty() {
691 let tool_call = &message.tool_calls[0];
692 if tool_call
693 .function
694 .name
695 .ends_with("create_conventional_analysis")
696 {
697 let args = &tool_call.function.arguments;
698 if args.is_empty() {
699 crate::style::warn(
700 "Model returned empty function arguments. Model may not support function \
701 calling properly.",
702 );
703 return Err(CommitGenError::Other(
704 "Model returned empty function arguments - try using a Claude model \
705 (sonnet/opus/haiku)"
706 .to_string(),
707 ));
708 }
709 let analysis: ConventionalAnalysis = serde_json::from_str(args).map_err(|e| {
710 CommitGenError::Other(format!(
711 "Failed to parse model response: {}. Response was: {}",
712 e,
713 args.chars().take(200).collect::<String>()
714 ))
715 })?;
716 return Ok((false, Some(analysis)));
717 }
718 }
719
720 if let Some(content) = &message.content {
722 if content.trim().is_empty() {
723 crate::style::warn("Model returned empty content for analysis; retrying.");
724 return Ok((true, None));
725 }
726 let analysis: ConventionalAnalysis =
727 serde_json::from_str(content.trim()).map_err(|e| {
728 CommitGenError::Other(format!(
729 "Failed to parse analysis content JSON: {e}. Content: {}",
730 response_snippet(content, 500)
731 ))
732 })?;
733 return Ok((false, Some(analysis)));
734 }
735
736 Err(CommitGenError::Other("No conventional analysis found in API response".to_string()))
737 },
738 ResolvedApiMode::AnthropicMessages => {
739 let (tool_input, text_content) =
740 extract_anthropic_content(&response_text, "create_conventional_analysis")?;
741
742 if let Some(input) = tool_input {
743 let analysis: ConventionalAnalysis = serde_json::from_value(input).map_err(|e| {
744 CommitGenError::Other(format!(
745 "Failed to parse analysis tool input: {e}. Response body: {}",
746 response_snippet(&response_text, 500)
747 ))
748 })?;
749 return Ok((false, Some(analysis)));
750 }
751
752 if text_content.trim().is_empty() {
753 crate::style::warn("Model returned empty content for analysis; retrying.");
754 return Ok((true, None));
755 }
756
757 let analysis: ConventionalAnalysis = serde_json::from_str(text_content.trim())
758 .map_err(|e| {
759 CommitGenError::Other(format!(
760 "Failed to parse analysis content JSON: {e}. Content: {}",
761 response_snippet(&text_content, 500)
762 ))
763 })?;
764 Ok((false, Some(analysis)))
765 },
766 }
767 }).await
768}
769
770fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
775 let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
776 let prefix = format!("{commit_type}{scope_part}: ");
777
778 summary
779 .strip_prefix(&prefix)
780 .or_else(|| {
781 let prefix_no_scope = format!("{commit_type}: ");
783 summary.strip_prefix(&prefix_no_scope)
784 })
785 .unwrap_or(summary)
786 .to_string()
787}
788
789fn validate_summary_quality(
791 summary: &str,
792 commit_type: &str,
793 stat: &str,
794) -> std::result::Result<(), String> {
795 use crate::validation::is_past_tense_verb;
796
797 let first_word = summary
798 .split_whitespace()
799 .next()
800 .ok_or_else(|| "summary is empty".to_string())?;
801
802 let first_word_lower = first_word.to_lowercase();
803
804 if !is_past_tense_verb(&first_word_lower) {
806 return Err(format!(
807 "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
808 ));
809 }
810
811 if first_word_lower == commit_type {
813 return Err(format!("repeats commit type '{commit_type}' in summary"));
814 }
815
816 let file_exts: Vec<&str> = stat
818 .lines()
819 .filter_map(|line| {
820 let path = line.split('|').next()?.trim();
821 std::path::Path::new(path).extension()?.to_str()
822 })
823 .collect();
824
825 if !file_exts.is_empty() {
826 let total = file_exts.len();
827 let md_count = file_exts.iter().filter(|&&e| e == "md").count();
828
829 if md_count * 100 / total > 80 && commit_type != "docs" {
831 crate::style::warn(&format!(
832 "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
833 md_count * 100 / total,
834 commit_type
835 ));
836 }
837
838 let code_exts = [
840 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
842 "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",
855 "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",
874 ];
875 let code_count = file_exts
876 .iter()
877 .filter(|&&e| code_exts.contains(&e))
878 .count();
879 if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
880 crate::style::warn(&format!(
881 "Type mismatch: no code files changed but type is '{commit_type}'"
882 ));
883 }
884 }
885
886 Ok(())
887}
888
889#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
891pub async fn generate_summary_from_analysis<'a>(
892 stat: &'a str,
893 commit_type: &'a str,
894 scope: Option<&'a str>,
895 details: &'a [String],
896 user_context: Option<&'a str>,
897 config: &'a CommitConfig,
898 debug_dir: Option<&'a Path>,
899 debug_prefix: Option<&'a str>,
900) -> Result<CommitSummary> {
901 let mut validation_attempt = 0;
902 let max_validation_retries = 1;
903 let mut last_failure_reason: Option<String> = None;
904
905 loop {
906 let additional_constraint = if let Some(reason) = &last_failure_reason {
907 format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
908 } else {
909 String::new()
910 };
911
912 let result = retry_api_call(config, async move || {
913 let bullet_points = details.join("\n");
915
916 let client = get_client(config);
917
918 let tool = Tool {
919 tool_type: "function".to_string(),
920 function: Function {
921 name: "create_commit_summary".to_string(),
922 description: "Compose a git commit summary line from detail statements".to_string(),
923 parameters: FunctionParameters {
924 param_type: "object".to_string(),
925 properties: serde_json::json!({
926 "summary": {
927 "type": "string",
928 "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
929 "maxLength": config.summary_hard_limit
930 }
931 }),
932 required: vec!["summary".to_string()],
933 },
934 },
935 };
936
937 let scope_str = scope.unwrap_or("");
939 let prefix_len =
940 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);
942
943 let mode = config.resolved_api_mode(&config.model);
944
945 let response_text = match mode {
946 ResolvedApiMode::ChatCompletions => {
947 let details_str = if bullet_points.is_empty() {
948 "None (no supporting detail points were generated)."
949 } else {
950 bullet_points.as_str()
951 };
952
953 let parts = templates::render_summary_prompt(
954 &config.summary_prompt_variant,
955 commit_type,
956 scope_str,
957 &max_summary_len.to_string(),
958 details_str,
959 stat.trim(),
960 user_context,
961 )?;
962
963 let user_content = format!("{}{additional_constraint}", parts.user);
964
965 let request = ApiRequest {
966 model: config.model.clone(),
967 max_tokens: 200,
968 temperature: config.temperature,
969 tools: vec![tool],
970 tool_choice: Some(serde_json::json!({
971 "type": "function",
972 "function": { "name": "create_commit_summary" }
973 })),
974 messages: vec![
975 Message { role: "system".to_string(), content: parts.system },
976 Message { role: "user".to_string(), content: user_content },
977 ],
978 };
979
980 if debug_dir.is_some() {
981 let request_json = serde_json::to_string_pretty(&request)?;
982 save_debug_output(
983 debug_dir,
984 &debug_filename(debug_prefix, "summary_request.json"),
985 &request_json,
986 )?;
987 }
988
989 let mut request_builder = client
990 .post(format!("{}/chat/completions", config.api_base_url))
991 .header("content-type", "application/json");
992
993 if let Some(api_key) = &config.api_key {
995 request_builder =
996 request_builder.header("Authorization", format!("Bearer {api_key}"));
997 }
998
999 let (status, response_text) =
1000 timed_send(request_builder.json(&request), "summary", &config.model).await?;
1001 if debug_dir.is_some() {
1002 save_debug_output(
1003 debug_dir,
1004 &debug_filename(debug_prefix, "summary_response.json"),
1005 &response_text,
1006 )?;
1007 }
1008
1009 if status.is_server_error() {
1011 eprintln!(
1012 "{}",
1013 crate::style::error(&format!("Server error {status}: {response_text}"))
1014 );
1015 return Ok((true, None)); }
1017
1018 if !status.is_success() {
1019 return Err(CommitGenError::ApiError {
1020 status: status.as_u16(),
1021 body: response_text,
1022 });
1023 }
1024
1025 response_text
1026 },
1027 ResolvedApiMode::AnthropicMessages => {
1028 let details_str = if bullet_points.is_empty() {
1029 "None (no supporting detail points were generated)."
1030 } else {
1031 bullet_points.as_str()
1032 };
1033
1034 let parts = templates::render_summary_prompt(
1035 &config.summary_prompt_variant,
1036 commit_type,
1037 scope_str,
1038 &max_summary_len.to_string(),
1039 details_str,
1040 stat.trim(),
1041 user_context,
1042 )?;
1043
1044 let user_content = format!("{}{additional_constraint}", parts.user);
1045
1046 let request = AnthropicRequest {
1047 model: config.model.clone(),
1048 max_tokens: 200,
1049 temperature: config.temperature,
1050 system: Some(parts.system).filter(|s| !s.is_empty()),
1051 tools: vec![AnthropicTool {
1052 name: "create_commit_summary".to_string(),
1053 description: "Compose a git commit summary line from detail statements"
1054 .to_string(),
1055 input_schema: serde_json::json!({
1056 "type": "object",
1057 "properties": {
1058 "summary": {
1059 "type": "string",
1060 "description": format!("Single line summary, target {} chars (hard limit {}), past tense verb first.", config.summary_guideline, config.summary_hard_limit),
1061 "maxLength": config.summary_hard_limit
1062 }
1063 },
1064 "required": ["summary"]
1065 }),
1066 }],
1067 tool_choice: Some(AnthropicToolChoice {
1068 choice_type: "tool".to_string(),
1069 name: "create_commit_summary".to_string(),
1070 }),
1071 messages: vec![AnthropicMessage {
1072 role: "user".to_string(),
1073 content: vec![AnthropicContent {
1074 content_type: "text".to_string(),
1075 text: user_content,
1076 }],
1077 }],
1078 };
1079
1080 if debug_dir.is_some() {
1081 let request_json = serde_json::to_string_pretty(&request)?;
1082 save_debug_output(
1083 debug_dir,
1084 &debug_filename(debug_prefix, "summary_request.json"),
1085 &request_json,
1086 )?;
1087 }
1088
1089 let mut request_builder = client
1090 .post(anthropic_messages_url(&config.api_base_url))
1091 .header("content-type", "application/json")
1092 .header("anthropic-version", "2023-06-01");
1093
1094 if let Some(api_key) = &config.api_key {
1095 request_builder = request_builder.header("x-api-key", api_key);
1096 }
1097
1098 let (status, response_text) =
1099 timed_send(request_builder.json(&request), "summary", &config.model).await?;
1100 if debug_dir.is_some() {
1101 save_debug_output(
1102 debug_dir,
1103 &debug_filename(debug_prefix, "summary_response.json"),
1104 &response_text,
1105 )?;
1106 }
1107
1108 if status.is_server_error() {
1110 eprintln!(
1111 "{}",
1112 crate::style::error(&format!("Server error {status}: {response_text}"))
1113 );
1114 return Ok((true, None)); }
1116
1117 if !status.is_success() {
1118 return Err(CommitGenError::ApiError {
1119 status: status.as_u16(),
1120 body: response_text,
1121 });
1122 }
1123
1124 response_text
1125 },
1126 };
1127
1128 if response_text.trim().is_empty() {
1129 crate::style::warn("Model returned empty response body for summary; retrying.");
1130 return Ok((true, None));
1131 }
1132
1133 match mode {
1134 ResolvedApiMode::ChatCompletions => {
1135 let api_response: ApiResponse =
1136 serde_json::from_str(&response_text).map_err(|e| {
1137 CommitGenError::Other(format!(
1138 "Failed to parse summary response JSON: {e}. Response body: {}",
1139 response_snippet(&response_text, 500)
1140 ))
1141 })?;
1142
1143 if api_response.choices.is_empty() {
1144 return Err(CommitGenError::Other(
1145 "Summary creation response was empty".to_string(),
1146 ));
1147 }
1148
1149 let message_choice = &api_response.choices[0].message;
1150
1151 if !message_choice.tool_calls.is_empty() {
1152 let tool_call = &message_choice.tool_calls[0];
1153 if tool_call.function.name.ends_with("create_commit_summary") {
1154 let args = &tool_call.function.arguments;
1155 if args.is_empty() {
1156 crate::style::warn(
1157 "Model returned empty function arguments for summary. Model may not \
1158 support function calling.",
1159 );
1160 return Err(CommitGenError::Other(
1161 "Model returned empty summary arguments - try using a Claude model \
1162 (sonnet/opus/haiku)"
1163 .to_string(),
1164 ));
1165 }
1166 let summary: SummaryOutput = serde_json::from_str(args).map_err(|e| {
1167 CommitGenError::Other(format!(
1168 "Failed to parse summary response: {}. Response was: {}",
1169 e,
1170 args.chars().take(200).collect::<String>()
1171 ))
1172 })?;
1173 let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1176 return Ok((
1177 false,
1178 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1179 ));
1180 }
1181 }
1182
1183 if let Some(content) = &message_choice.content {
1184 if content.trim().is_empty() {
1185 crate::style::warn("Model returned empty content for summary; retrying.");
1186 return Ok((true, None));
1187 }
1188 let trimmed = content.trim();
1190 let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1191 Ok(summary) => summary.summary,
1192 Err(e) => {
1193 if trimmed.starts_with('{') {
1195 return Err(CommitGenError::Other(format!(
1196 "Failed to parse summary JSON: {e}. Content: {}",
1197 response_snippet(trimmed, 500)
1198 )));
1199 }
1200 trimmed.to_string()
1202 },
1203 };
1204 let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1206 return Ok((
1207 false,
1208 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1209 ));
1210 }
1211
1212 Err(CommitGenError::Other(
1213 "No summary found in summary creation response".to_string(),
1214 ))
1215 },
1216 ResolvedApiMode::AnthropicMessages => {
1217 let (tool_input, text_content) =
1218 extract_anthropic_content(&response_text, "create_commit_summary")?;
1219
1220 if let Some(input) = tool_input {
1221 let summary: SummaryOutput = serde_json::from_value(input).map_err(|e| {
1222 CommitGenError::Other(format!(
1223 "Failed to parse summary tool input: {e}. Response body: {}",
1224 response_snippet(&response_text, 500)
1225 ))
1226 })?;
1227 let cleaned = strip_type_prefix(&summary.summary, commit_type, scope);
1228 return Ok((
1229 false,
1230 Some(CommitSummary::new(cleaned, config.summary_hard_limit)?),
1231 ));
1232 }
1233
1234 if text_content.trim().is_empty() {
1235 crate::style::warn("Model returned empty content for summary; retrying.");
1236 return Ok((true, None));
1237 }
1238
1239 let trimmed = text_content.trim();
1241 let summary_text = match serde_json::from_str::<SummaryOutput>(trimmed) {
1242 Ok(summary) => summary.summary,
1243 Err(e) => {
1244 if trimmed.starts_with('{') {
1246 return Err(CommitGenError::Other(format!(
1247 "Failed to parse summary JSON: {e}. Content: {}",
1248 response_snippet(trimmed, 500)
1249 )));
1250 }
1251 trimmed.to_string()
1253 },
1254 };
1255 let cleaned = strip_type_prefix(&summary_text, commit_type, scope);
1256 Ok((false, Some(CommitSummary::new(cleaned, config.summary_hard_limit)?)))
1257 },
1258 }
1259 }).await;
1260
1261 match result {
1262 Ok(summary) => {
1263 match validate_summary_quality(summary.as_str(), commit_type, stat) {
1265 Ok(()) => return Ok(summary),
1266 Err(reason) if validation_attempt < max_validation_retries => {
1267 crate::style::warn(&format!(
1268 "Validation failed (attempt {}/{}): {}",
1269 validation_attempt + 1,
1270 max_validation_retries + 1,
1271 reason
1272 ));
1273 last_failure_reason = Some(reason);
1274 validation_attempt += 1;
1275 },
1277 Err(reason) => {
1278 crate::style::warn(&format!(
1279 "Validation failed after {} retries: {}. Using fallback.",
1280 max_validation_retries + 1,
1281 reason
1282 ));
1283 return Ok(fallback_from_details_or_summary(
1285 details,
1286 summary.as_str(),
1287 commit_type,
1288 config,
1289 ));
1290 },
1291 }
1292 },
1293 Err(e) => return Err(e),
1294 }
1295 }
1296}
1297
1298fn fallback_from_details_or_summary(
1300 details: &[String],
1301 invalid_summary: &str,
1302 commit_type: &str,
1303 config: &CommitConfig,
1304) -> CommitSummary {
1305 let candidate = if let Some(first_detail) = details.first() {
1306 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1308
1309 let type_word_variants =
1311 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1312 for variant in &type_word_variants {
1313 if cleaned
1314 .to_lowercase()
1315 .starts_with(&format!("{} ", variant.to_lowercase()))
1316 {
1317 cleaned = cleaned[variant.len()..].trim().to_string();
1318 break;
1319 }
1320 }
1321
1322 cleaned
1323 } else {
1324 let mut cleaned = invalid_summary
1326 .split_whitespace()
1327 .skip(1) .collect::<Vec<_>>()
1329 .join(" ");
1330
1331 if cleaned.is_empty() {
1332 cleaned = fallback_summary("", details, commit_type, config)
1333 .as_str()
1334 .to_string();
1335 }
1336
1337 cleaned
1338 };
1339
1340 let with_verb = if candidate
1342 .split_whitespace()
1343 .next()
1344 .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
1345 {
1346 candidate
1347 } else {
1348 let verb = match commit_type {
1349 "feat" => "added",
1350 "fix" => "fixed",
1351 "refactor" => "restructured",
1352 "docs" => "documented",
1353 "test" => "tested",
1354 "perf" => "optimized",
1355 "build" | "ci" | "chore" => "updated",
1356 "style" => "formatted",
1357 "revert" => "reverted",
1358 _ => "changed",
1359 };
1360 format!("{verb} {candidate}")
1361 };
1362
1363 CommitSummary::new(with_verb, config.summary_hard_limit)
1364 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1365}
1366
1367pub fn fallback_summary(
1369 stat: &str,
1370 details: &[String],
1371 commit_type: &str,
1372 config: &CommitConfig,
1373) -> CommitSummary {
1374 let mut candidate = if let Some(first) = details.first() {
1375 first.trim().trim_end_matches('.').to_string()
1376 } else {
1377 let primary_line = stat
1378 .lines()
1379 .map(str::trim)
1380 .find(|line| !line.is_empty())
1381 .unwrap_or("files");
1382
1383 let subject = primary_line
1384 .split('|')
1385 .next()
1386 .map(str::trim)
1387 .filter(|s| !s.is_empty())
1388 .unwrap_or("files");
1389
1390 if subject.eq_ignore_ascii_case("files") {
1391 "Updated files".to_string()
1392 } else {
1393 format!("Updated {subject}")
1394 }
1395 };
1396
1397 candidate = candidate
1398 .replace(['\n', '\r'], " ")
1399 .split_whitespace()
1400 .collect::<Vec<_>>()
1401 .join(" ")
1402 .trim()
1403 .trim_end_matches('.')
1404 .trim_end_matches(';')
1405 .trim_end_matches(':')
1406 .to_string();
1407
1408 if candidate.is_empty() {
1409 candidate = "Updated files".to_string();
1410 }
1411
1412 const CONSERVATIVE_MAX: usize = 50;
1415 while candidate.len() > CONSERVATIVE_MAX {
1416 if let Some(pos) = candidate.rfind(' ') {
1417 candidate.truncate(pos);
1418 candidate = candidate.trim_end_matches(',').trim().to_string();
1419 } else {
1420 candidate.truncate(CONSERVATIVE_MAX);
1421 break;
1422 }
1423 }
1424
1425 candidate = candidate.trim_end_matches('.').to_string();
1427
1428 if candidate
1431 .split_whitespace()
1432 .next()
1433 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1434 {
1435 candidate = match commit_type {
1436 "refactor" => "restructured change".to_string(),
1437 "feat" => "added functionality".to_string(),
1438 "fix" => "fixed issue".to_string(),
1439 "docs" => "documented updates".to_string(),
1440 "test" => "tested changes".to_string(),
1441 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1442 "perf" => "optimized performance".to_string(),
1443 "revert" => "reverted previous commit".to_string(),
1444 _ => "updated files".to_string(),
1445 };
1446 }
1447
1448 CommitSummary::new(candidate, config.summary_hard_limit)
1451 .expect("fallback summary should always be valid")
1452}
1453
1454pub async fn generate_analysis_with_map_reduce<'a>(
1459 stat: &'a str,
1460 diff: &'a str,
1461 model_name: &'a str,
1462 scope_candidates_str: &'a str,
1463 ctx: &AnalysisContext<'a>,
1464 config: &'a CommitConfig,
1465 counter: &TokenCounter,
1466) -> Result<ConventionalAnalysis> {
1467 use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1468
1469 if should_use_map_reduce(diff, config, counter) {
1470 crate::style::print_info(&format!(
1471 "Large diff detected ({} tokens), using map-reduce...",
1472 counter.count_sync(diff)
1473 ));
1474 run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1475 } else {
1476 generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1477 .await
1478 }
1479}
1480
1481#[cfg(test)]
1482mod tests {
1483 use super::*;
1484 use crate::config::CommitConfig;
1485
1486 #[test]
1487 fn test_validate_summary_quality_valid() {
1488 let stat = "src/main.rs | 10 +++++++---\n";
1489 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
1490 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
1491 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
1492 }
1493
1494 #[test]
1495 fn test_validate_summary_quality_invalid_verb() {
1496 let stat = "src/main.rs | 10 +++++++---\n";
1497 let result = validate_summary_quality("adding new feature", "feat", stat);
1498 assert!(result.is_err());
1499 assert!(result.unwrap_err().contains("past-tense verb"));
1500 }
1501
1502 #[test]
1503 fn test_validate_summary_quality_type_repetition() {
1504 let stat = "src/main.rs | 10 +++++++---\n";
1505 let result = validate_summary_quality("feat new feature", "feat", stat);
1507 assert!(result.is_err());
1508 assert!(result.unwrap_err().contains("past-tense verb"));
1509
1510 let result = validate_summary_quality("fix bug", "fix", stat);
1512 assert!(result.is_err());
1513 assert!(result.unwrap_err().contains("past-tense verb"));
1515 }
1516
1517 #[test]
1518 fn test_validate_summary_quality_empty() {
1519 let stat = "src/main.rs | 10 +++++++---\n";
1520 let result = validate_summary_quality("", "feat", stat);
1521 assert!(result.is_err());
1522 assert!(result.unwrap_err().contains("empty"));
1523 }
1524
1525 #[test]
1526 fn test_validate_summary_quality_markdown_type_mismatch() {
1527 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
1528 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
1530 }
1531
1532 #[test]
1533 fn test_validate_summary_quality_no_code_files() {
1534 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
1535 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
1537 }
1538
1539 #[test]
1540 fn test_fallback_from_details_with_first_detail() {
1541 let config = CommitConfig::default();
1542 let details = vec![
1543 "Added authentication middleware.".to_string(),
1544 "Updated error handling.".to_string(),
1545 ];
1546 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
1547 assert_eq!(result.as_str(), "Added authentication middleware");
1549 }
1550
1551 #[test]
1552 fn test_fallback_from_details_strips_type_word() {
1553 let config = CommitConfig::default();
1554 let details = vec!["Featuring new oauth flow.".to_string()];
1555 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
1556 assert!(result.as_str().starts_with("added"));
1559 }
1560
1561 #[test]
1562 fn test_fallback_from_details_no_details() {
1563 let config = CommitConfig::default();
1564 let details: Vec<String> = vec![];
1565 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
1566 assert!(result.as_str().starts_with("added"));
1568 }
1569
1570 #[test]
1571 fn test_fallback_from_details_adds_verb() {
1572 let config = CommitConfig::default();
1573 let details = vec!["configuration for oauth".to_string()];
1574 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
1575 assert_eq!(result.as_str(), "added configuration for oauth");
1576 }
1577
1578 #[test]
1579 fn test_fallback_from_details_preserves_existing_verb() {
1580 let config = CommitConfig::default();
1581 let details = vec!["fixed authentication bug".to_string()];
1582 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
1583 assert_eq!(result.as_str(), "fixed authentication bug");
1584 }
1585
1586 #[test]
1587 fn test_fallback_from_details_type_specific_verbs() {
1588 let config = CommitConfig::default();
1589 let details = vec!["module structure".to_string()];
1590
1591 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
1592 assert_eq!(result.as_str(), "restructured module structure");
1593
1594 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
1595 assert_eq!(result.as_str(), "documented module structure");
1596
1597 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
1598 assert_eq!(result.as_str(), "tested module structure");
1599
1600 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
1601 assert_eq!(result.as_str(), "optimized module structure");
1602 }
1603
1604 #[test]
1605 fn test_fallback_summary_with_stat() {
1606 let config = CommitConfig::default();
1607 let stat = "src/main.rs | 10 +++++++---\n";
1608 let details = vec![];
1609 let result = fallback_summary(stat, &details, "feat", &config);
1610 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
1611 }
1612
1613 #[test]
1614 fn test_fallback_summary_with_details() {
1615 let config = CommitConfig::default();
1616 let stat = "";
1617 let details = vec!["First detail here.".to_string()];
1618 let result = fallback_summary(stat, &details, "feat", &config);
1619 assert_eq!(result.as_str(), "First detail here");
1621 }
1622
1623 #[test]
1624 fn test_fallback_summary_no_stat_no_details() {
1625 let config = CommitConfig::default();
1626 let result = fallback_summary("", &[], "feat", &config);
1627 assert_eq!(result.as_str(), "Updated files");
1629 }
1630
1631 #[test]
1632 fn test_fallback_summary_type_word_overlap() {
1633 let config = CommitConfig::default();
1634 let details = vec!["refactor was performed".to_string()];
1635 let result = fallback_summary("", &details, "refactor", &config);
1636 assert_eq!(result.as_str(), "restructured change");
1638 }
1639
1640 #[test]
1641 fn test_fallback_summary_length_limit() {
1642 let config = CommitConfig::default();
1643 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
1645 let result = fallback_summary("", &details, "feat", &config);
1646 assert!(result.len() <= 50);
1648 }
1649}