1use std::{
2 collections::HashMap,
3 path::Path,
4 sync::{LazyLock, OnceLock},
5 time::Duration,
6};
7
8use parking_lot::{Condvar, Mutex};
9use serde::{Deserialize, Serialize, de::DeserializeOwned};
10
11use crate::{
12 config::{CommitConfig, ResolvedApiMode},
13 error::{CommitGenError, Result},
14 templates,
15 tokens::TokenCounter,
16 types::{
17 CommitSummary, CommitType, ConventionalAnalysis, ConventionalCommit, coerce_optional_scope,
18 },
19};
20
21static TRACE_ENABLED: LazyLock<bool> = LazyLock::new(|| std::env::var("LLM_GIT_TRACE").is_ok());
23
24fn trace_enabled() -> bool {
26 *TRACE_ENABLED
27}
28
29pub async fn timed_send(
34 request_builder: reqwest::RequestBuilder,
35 label: &str,
36 model: &str,
37) -> std::result::Result<(reqwest::StatusCode, String), CommitGenError> {
38 let trace = trace_enabled();
39 let start = std::time::Instant::now();
40
41 let response = request_builder
42 .send()
43 .await
44 .map_err(CommitGenError::HttpError)?;
45
46 let ttft = start.elapsed();
47 let status = response.status();
48 let content_length = response.content_length();
49
50 let body = response.text().await.map_err(CommitGenError::HttpError)?;
51 let total = start.elapsed();
52
53 if trace {
54 let size_info = content_length.map_or_else(
55 || format!("{}B", body.len()),
56 |cl| format!("{}B (content-length: {cl})", body.len()),
57 );
58 if !crate::style::pipe_mode() {
60 print!("\r\x1b[K");
61 std::io::Write::flush(&mut std::io::stdout()).ok();
62 }
63 eprintln!(
64 "[TRACE] {label} model={model} status={status} ttft={ttft:.0?} total={total:.0?} \
65 body={size_info}"
66 );
67 }
68
69 Ok((status, body))
70}
71
72#[derive(Default)]
76pub struct AnalysisContext<'a> {
77 pub user_context: Option<&'a str>,
79 pub recent_commits: Option<&'a str>,
81 pub common_scopes: Option<&'a str>,
83 pub project_context: Option<&'a str>,
85 pub debug_output: Option<&'a Path>,
87 pub debug_prefix: Option<&'a str>,
89}
90
91static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
93
94pub fn get_client(config: &CommitConfig) -> &'static reqwest::Client {
99 CLIENT.get_or_init(|| {
100 reqwest::Client::builder()
101 .timeout(Duration::from_secs(config.request_timeout_secs))
102 .connect_timeout(Duration::from_secs(config.connect_timeout_secs))
103 .build()
104 .expect("Failed to build HTTP client")
105 })
106}
107
108fn debug_filename(prefix: Option<&str>, name: &str) -> String {
109 match prefix {
110 Some(p) if !p.is_empty() => format!("{p}_{name}"),
111 _ => name.to_string(),
112 }
113}
114
115fn response_snippet(body: &str, limit: usize) -> String {
116 if body.is_empty() {
117 return "<empty response body>".to_string();
118 }
119 let mut snippet = body.trim().to_string();
120 if snippet.len() > limit {
121 snippet.truncate(limit);
122 snippet.push_str("...");
123 }
124 snippet
125}
126
127fn save_debug_output(debug_dir: Option<&Path>, filename: &str, content: &str) -> Result<()> {
128 let Some(dir) = debug_dir else {
129 return Ok(());
130 };
131
132 std::fs::create_dir_all(dir)?;
133 let path = dir.join(filename);
134 std::fs::write(&path, content)?;
135 Ok(())
136}
137
138fn anthropic_messages_url(base_url: &str) -> String {
139 let trimmed = base_url.trim_end_matches('/');
140 if trimmed.ends_with("/v1") {
141 format!("{trimmed}/messages")
142 } else {
143 format!("{trimmed}/v1/messages")
144 }
145}
146
147fn prompt_cache_control() -> PromptCacheControl {
148 PromptCacheControl { control_type: "ephemeral".to_string() }
149}
150
151fn anthropic_prompt_caching_enabled(config: &CommitConfig) -> bool {
152 config.api_base_url.to_lowercase().contains("anthropic.com")
153}
154
155fn append_anthropic_cache_beta_header(
156 request_builder: reqwest::RequestBuilder,
157 enable_cache: bool,
158) -> reqwest::RequestBuilder {
159 if enable_cache {
160 request_builder.header("anthropic-beta", "prompt-caching-2024-07-31")
161 } else {
162 request_builder
163 }
164}
165
166fn anthropic_text_content(text: String, cache: bool) -> AnthropicContent {
167 AnthropicContent {
168 content_type: "text".to_string(),
169 text,
170 cache_control: cache.then(prompt_cache_control),
171 }
172}
173
174fn anthropic_system_content(system_prompt: &str, cache: bool) -> Option<Vec<AnthropicContent>> {
175 if system_prompt.trim().is_empty() {
176 None
177 } else {
178 Some(vec![anthropic_text_content(system_prompt.to_string(), cache)])
179 }
180}
181
182fn supports_openai_prompt_cache_key(config: &CommitConfig) -> bool {
183 config
184 .api_base_url
185 .to_lowercase()
186 .contains("api.openai.com")
187}
188
189pub fn openai_prompt_cache_key(
191 config: &CommitConfig,
192 model_name: &str,
193 prompt_family: &str,
194 prompt_variant: &str,
195 system_prompt: &str,
196) -> Option<String> {
197 if system_prompt.trim().is_empty() || !supports_openai_prompt_cache_key(config) {
198 return None;
199 }
200
201 Some(format!("llm-git:v1:{model_name}:{prompt_family}:{prompt_variant}"))
202}
203
204pub fn strict_json_schema(properties: serde_json::Value, required: &[&str]) -> serde_json::Value {
205 serde_json::json!({
206 "type": "object",
207 "properties": properties,
208 "required": required,
209 "additionalProperties": false
210 })
211}
212
213pub fn openai_response_format(name: &str, schema: serde_json::Value) -> serde_json::Value {
214 serde_json::json!({
215 "type": "json_schema",
216 "json_schema": {
217 "name": name,
218 "strict": true,
219 "schema": schema
220 }
221 })
222}
223
224pub fn anthropic_output_format(schema: serde_json::Value) -> serde_json::Value {
225 serde_json::json!({
226 "type": "json_schema",
227 "schema": schema
228 })
229}
230
231pub(crate) fn extract_json_from_content(content: &str) -> String {
232 let trimmed = content.trim();
233
234 if trimmed.is_empty() {
235 return String::new();
236 }
237
238 if let Some(start) = trimmed.find("```json") {
239 let after_marker = &trimmed[start + 7..];
240 if let Some(end) = after_marker.find("```") {
241 return after_marker[..end].trim().to_string();
242 }
243 }
244
245 if let Some(start) = trimmed.find("```") {
246 let after_marker = &trimmed[start + 3..];
247 let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
248 let after_newline = &after_marker[content_start..];
249 if let Some(end) = after_newline.find("```") {
250 return after_newline[..end].trim().to_string();
251 }
252 }
253
254 if let Some(start) = trimmed.find('{')
255 && let Some(end) = trimmed.rfind('}')
256 && end >= start
257 {
258 return trimmed[start..=end].to_string();
259 }
260
261 trimmed.to_string()
262}
263
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum OneShotSource {
266 StructuredOutput,
267 ToolCall,
268 OutputJsonParse,
269 PlainTextContent,
270 Cache,
271}
272
273#[derive(Debug, Clone, Copy)]
274pub struct OneShotDebug<'a> {
275 pub dir: Option<&'a Path>,
276 pub prefix: Option<&'a str>,
277 pub name: &'a str,
278}
279
280#[derive(Debug, Clone, Copy)]
281pub struct OneShotSpec<'a> {
282 pub operation: &'a str,
283 pub model: &'a str,
284 pub max_tokens: u32,
285 pub temperature: f32,
286 pub prompt_family: &'a str,
287 pub prompt_variant: &'a str,
288 pub system_prompt: &'a str,
289 pub user_prompt: &'a str,
290 pub tool_name: &'a str,
291 pub tool_description: &'a str,
292 pub schema: &'a serde_json::Value,
293 pub debug: Option<OneShotDebug<'a>>,
294 pub cacheable: bool,
297}
298
299#[derive(Debug)]
300pub struct OneShotResponse<T> {
301 pub output: T,
302 pub source: OneShotSource,
303 pub text_content: Option<String>,
304 pub stop_reason: Option<String>,
305}
306
307#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308enum OneShotRequestKind {
309 StructuredOutput,
310 ToolCalling,
311}
312
313impl OneShotRequestKind {
314 const fn debug_label(self) -> &'static str {
315 match self {
316 Self::StructuredOutput => "structured",
317 Self::ToolCalling => "tool",
318 }
319 }
320
321 const fn content_source(self) -> OneShotSource {
322 match self {
323 Self::StructuredOutput => OneShotSource::StructuredOutput,
324 Self::ToolCalling => OneShotSource::OutputJsonParse,
325 }
326 }
327}
328
329enum OneShotRequestOutcome {
330 Response(String),
331 Retry,
332 FallbackToTool,
333}
334
335enum OneShotParseOutcome<T> {
336 Success(OneShotResponse<T>),
337 Retry,
338 Fatal(CommitGenError),
339}
340
341fn save_oneshot_debug<T: Serialize>(
342 debug: Option<OneShotDebug<'_>>,
343 kind: OneShotRequestKind,
344 phase: &str,
345 value: &T,
346) -> Result<()> {
347 let Some(debug) = debug else {
348 return Ok(());
349 };
350
351 let filename = debug_filename(
352 debug.prefix,
353 &format!("{}_{}_{}.json", debug.name, kind.debug_label(), phase),
354 );
355 let json = serde_json::to_string_pretty(value)?;
356 save_debug_output(debug.dir, &filename, &json)
357}
358
359fn save_oneshot_debug_text(
360 debug: Option<OneShotDebug<'_>>,
361 kind: OneShotRequestKind,
362 phase: &str,
363 text: &str,
364) -> Result<()> {
365 let Some(debug) = debug else {
366 return Ok(());
367 };
368
369 let filename = debug_filename(
370 debug.prefix,
371 &format!("{}_{}_{}.json", debug.name, kind.debug_label(), phase),
372 );
373 save_debug_output(debug.dir, &filename, text)
374}
375
376fn schema_properties(schema: &serde_json::Value) -> Result<serde_json::Value> {
377 schema
378 .get("properties")
379 .cloned()
380 .ok_or_else(|| CommitGenError::Other("Schema must include top-level properties".to_string()))
381}
382
383fn schema_required(schema: &serde_json::Value) -> Result<Vec<String>> {
384 schema
385 .get("required")
386 .and_then(|value| value.as_array())
387 .ok_or_else(|| {
388 CommitGenError::Other("Schema must include top-level required array".to_string())
389 })
390 .and_then(|values| {
391 values
392 .iter()
393 .map(|value| {
394 value.as_str().map(str::to_string).ok_or_else(|| {
395 CommitGenError::Other("Schema required entries must be strings".to_string())
396 })
397 })
398 .collect()
399 })
400}
401
402fn build_openai_tool(
403 tool_name: &str,
404 tool_description: &str,
405 schema: &serde_json::Value,
406) -> Result<Tool> {
407 Ok(Tool {
408 tool_type: "function".to_string(),
409 function: Function {
410 name: tool_name.to_string(),
411 description: tool_description.to_string(),
412 parameters: FunctionParameters {
413 param_type: "object".to_string(),
414 properties: schema_properties(schema)?,
415 required: schema_required(schema)?,
416 },
417 },
418 })
419}
420
421fn build_anthropic_tool(
422 tool_name: &str,
423 tool_description: &str,
424 schema: &serde_json::Value,
425 prompt_caching: bool,
426 kind: OneShotRequestKind,
427) -> AnthropicTool {
428 let mut tool = AnthropicTool {
429 name: tool_name.to_string(),
430 description: tool_description.to_string(),
431 input_schema: schema.clone(),
432 cache_control: None,
433 };
434
435 if kind == OneShotRequestKind::ToolCalling && prompt_caching {
436 tool.cache_control = Some(prompt_cache_control());
437 }
438
439 tool
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443enum StructuredOutputCapability {
444 Probing,
445 Supported,
446 Unsupported,
447}
448
449struct StructuredOutputCapabilityCache {
450 states: Mutex<HashMap<String, StructuredOutputCapability>>,
451 condvar: Condvar,
452}
453
454static STRUCTURED_OUTPUT_CAPABILITIES: LazyLock<StructuredOutputCapabilityCache> =
455 LazyLock::new(|| StructuredOutputCapabilityCache {
456 states: Mutex::new(HashMap::new()),
457 condvar: Condvar::new(),
458 });
459
460#[derive(Debug, Clone, Copy, PartialEq, Eq)]
461enum StructuredOutputAttempt {
462 Probe,
463 Supported,
464 SkipUnsupported,
465}
466
467fn structured_output_cache_key(
468 config: &CommitConfig,
469 model: &str,
470 mode: ResolvedApiMode,
471) -> String {
472 format!(
473 "{:?}:{}:{}",
474 mode,
475 config.api_base_url.trim().to_lowercase(),
476 model.trim().to_lowercase()
477 )
478}
479
480fn begin_structured_output_attempt(
481 config: &CommitConfig,
482 model: &str,
483 mode: ResolvedApiMode,
484) -> StructuredOutputAttempt {
485 let key = structured_output_cache_key(config, model, mode);
486
487 loop {
488 let mut states = STRUCTURED_OUTPUT_CAPABILITIES.states.lock();
489 match states.get(&key).copied() {
490 Some(StructuredOutputCapability::Unsupported) => {
491 return StructuredOutputAttempt::SkipUnsupported;
492 },
493 Some(StructuredOutputCapability::Supported) => {
494 return StructuredOutputAttempt::Supported;
495 },
496 Some(StructuredOutputCapability::Probing) => {
497 STRUCTURED_OUTPUT_CAPABILITIES.condvar.wait(&mut states);
498 },
499 None => {
500 states.insert(key.clone(), StructuredOutputCapability::Probing);
501 return StructuredOutputAttempt::Probe;
502 },
503 }
504 }
505}
506
507fn update_structured_output_capability(
508 config: &CommitConfig,
509 model: &str,
510 mode: ResolvedApiMode,
511 state: Option<StructuredOutputCapability>,
512) -> bool {
513 let key = structured_output_cache_key(config, model, mode);
514 let mut states = STRUCTURED_OUTPUT_CAPABILITIES.states.lock();
515 let previous = match state {
516 Some(state) => states.insert(key, state),
517 None => states.remove(&key),
518 };
519 STRUCTURED_OUTPUT_CAPABILITIES.condvar.notify_all();
520
521 matches!(state, Some(StructuredOutputCapability::Unsupported))
522 && previous != Some(StructuredOutputCapability::Unsupported)
523}
524
525fn is_official_anthropic_base_url(api_base_url: &str) -> bool {
526 api_base_url
527 .trim()
528 .to_lowercase()
529 .contains("api.anthropic.com")
530}
531
532fn is_anthropic_model(model: &str) -> bool {
533 let lower = model.trim().to_lowercase();
534 lower.starts_with("claude")
535 || lower.starts_with("anthropic/")
536 || lower.contains("/claude")
537 || lower.contains("anthropic.claude")
538}
539
540fn prefers_tool_calling_over_structured_output(model: &str) -> bool {
541 let lower = model.trim().to_lowercase();
542 lower.contains("codex-spark")
543}
544
545fn should_attempt_structured_output(config: &CommitConfig, model: &str) -> bool {
546 if prefers_tool_calling_over_structured_output(model) {
547 return false;
548 }
549
550 !is_anthropic_model(model) || is_official_anthropic_base_url(&config.api_base_url)
551}
552
553fn should_fallback_to_tool(status: reqwest::StatusCode, body: &str) -> bool {
554 if matches!(status.as_u16(), 401 | 403 | 429) {
555 return false;
556 }
557
558 let lower = body.to_lowercase();
559 [
560 "response_format",
561 "output_format",
562 "output_config",
563 "structured output",
564 "structured_outputs",
565 "json_schema",
566 "responsejsonschema",
567 "response schema",
568 ]
569 .iter()
570 .any(|needle| lower.contains(needle))
571}
572
573async fn send_oneshot_request(
574 config: &CommitConfig,
575 spec: &OneShotSpec<'_>,
576 mode: ResolvedApiMode,
577 kind: OneShotRequestKind,
578) -> Result<OneShotRequestOutcome> {
579 match mode {
580 ResolvedApiMode::ChatCompletions => {
581 let tool = build_openai_tool(spec.tool_name, spec.tool_description, spec.schema)?;
582 let prompt_cache_key = openai_prompt_cache_key(
583 config,
584 spec.model,
585 spec.prompt_family,
586 spec.prompt_variant,
587 spec.system_prompt,
588 );
589 let mut messages = Vec::new();
590 if !spec.system_prompt.trim().is_empty() {
591 messages.push(Message {
592 role: "system".to_string(),
593 content: spec.system_prompt.to_string(),
594 });
595 }
596 messages
597 .push(Message { role: "user".to_string(), content: spec.user_prompt.to_string() });
598
599 let request = ApiRequest {
600 model: spec.model.to_string(),
601 max_tokens: spec.max_tokens,
602 temperature: spec.temperature,
603 tools: if kind == OneShotRequestKind::ToolCalling {
604 vec![tool]
605 } else {
606 Vec::new()
607 },
608 tool_choice: (kind == OneShotRequestKind::ToolCalling)
609 .then(|| serde_json::json!("required")),
610 response_format: (kind == OneShotRequestKind::StructuredOutput)
611 .then(|| openai_response_format(spec.tool_name, spec.schema.clone())),
612 prompt_cache_key,
613 messages,
614 };
615
616 save_oneshot_debug(spec.debug, kind, "request", &request)?;
617
618 let client = get_client(config);
619 let mut request_builder = client
620 .post(format!("{}/chat/completions", config.api_base_url))
621 .header("content-type", "application/json");
622
623 if let Some(api_key) = &config.api_key {
624 request_builder = request_builder.header("Authorization", format!("Bearer {api_key}"));
625 }
626
627 let (status, response_text) =
628 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
629 save_oneshot_debug_text(spec.debug, kind, "response", &response_text)?;
630
631 if status.is_server_error() {
632 if kind == OneShotRequestKind::StructuredOutput
633 && should_fallback_to_tool(status, &response_text)
634 {
635 crate::style::warn(&format!(
636 "Structured output request failed for {} (HTTP {}). Falling back to tool \
637 calling.",
638 spec.operation, status
639 ));
640 return Ok(OneShotRequestOutcome::FallbackToTool);
641 }
642 eprintln!(
643 "{}",
644 crate::style::error(&format!("Server error {status}: {response_text}"))
645 );
646 return Ok(OneShotRequestOutcome::Retry);
647 }
648
649 if !status.is_success() {
650 if kind == OneShotRequestKind::StructuredOutput
651 && should_fallback_to_tool(status, &response_text)
652 {
653 crate::style::warn(&format!(
654 "Structured output request failed for {} (HTTP {}). Falling back to tool \
655 calling.",
656 spec.operation, status
657 ));
658 return Ok(OneShotRequestOutcome::FallbackToTool);
659 }
660 return Err(CommitGenError::ApiError {
661 status: status.as_u16(),
662 body: response_text,
663 });
664 }
665
666 if response_text.trim().is_empty() {
667 crate::style::warn(&format!(
668 "Model returned empty response body for {}; retrying.",
669 spec.operation
670 ));
671 return Ok(OneShotRequestOutcome::Retry);
672 }
673
674 Ok(OneShotRequestOutcome::Response(response_text))
675 },
676 ResolvedApiMode::AnthropicMessages => {
677 let prompt_caching = anthropic_prompt_caching_enabled(config);
678 let tools = if kind == OneShotRequestKind::ToolCalling {
679 vec![build_anthropic_tool(
680 spec.tool_name,
681 spec.tool_description,
682 spec.schema,
683 prompt_caching,
684 kind,
685 )]
686 } else {
687 Vec::new()
688 };
689 let request = AnthropicRequest {
690 model: spec.model.to_string(),
691 max_tokens: spec.max_tokens,
692 temperature: spec.temperature,
693 system: anthropic_system_content(spec.system_prompt, prompt_caching),
694 tools,
695 tool_choice: (kind == OneShotRequestKind::ToolCalling).then(|| AnthropicToolChoice {
696 choice_type: "tool".to_string(),
697 name: spec.tool_name.to_string(),
698 }),
699 output_format: (kind == OneShotRequestKind::StructuredOutput)
700 .then(|| anthropic_output_format(spec.schema.clone())),
701 messages: vec![AnthropicMessage {
702 role: "user".to_string(),
703 content: vec![anthropic_text_content(spec.user_prompt.to_string(), false)],
704 }],
705 };
706
707 save_oneshot_debug(spec.debug, kind, "request", &request)?;
708
709 let client = get_client(config);
710 let mut request_builder = append_anthropic_cache_beta_header(
711 client
712 .post(anthropic_messages_url(&config.api_base_url))
713 .header("content-type", "application/json")
714 .header("anthropic-version", "2023-06-01"),
715 prompt_caching,
716 );
717
718 if let Some(api_key) = &config.api_key {
719 request_builder = request_builder.header("x-api-key", api_key);
720 }
721
722 let (status, response_text) =
723 timed_send(request_builder.json(&request), spec.operation, spec.model).await?;
724 save_oneshot_debug_text(spec.debug, kind, "response", &response_text)?;
725
726 if status.is_server_error() {
727 if kind == OneShotRequestKind::StructuredOutput
728 && should_fallback_to_tool(status, &response_text)
729 {
730 crate::style::warn(&format!(
731 "Structured output request failed for {} (HTTP {}). Falling back to tool \
732 calling.",
733 spec.operation, status
734 ));
735 return Ok(OneShotRequestOutcome::FallbackToTool);
736 }
737 eprintln!(
738 "{}",
739 crate::style::error(&format!("Server error {status}: {response_text}"))
740 );
741 return Ok(OneShotRequestOutcome::Retry);
742 }
743
744 if !status.is_success() {
745 if kind == OneShotRequestKind::StructuredOutput
746 && should_fallback_to_tool(status, &response_text)
747 {
748 crate::style::warn(&format!(
749 "Structured output request failed for {} (HTTP {}). Falling back to tool \
750 calling.",
751 spec.operation, status
752 ));
753 return Ok(OneShotRequestOutcome::FallbackToTool);
754 }
755 return Err(CommitGenError::ApiError {
756 status: status.as_u16(),
757 body: response_text,
758 });
759 }
760
761 if response_text.trim().is_empty() {
762 crate::style::warn(&format!(
763 "Model returned empty response body for {}; retrying.",
764 spec.operation
765 ));
766 return Ok(OneShotRequestOutcome::Retry);
767 }
768
769 Ok(OneShotRequestOutcome::Response(response_text))
770 },
771 }
772}
773
774fn parse_json_output<T: DeserializeOwned>(json_text: &str, error_label: &str) -> Result<T> {
775 let candidate = extract_json_from_content(json_text);
776 serde_json::from_str(&candidate).map_err(|e| {
777 CommitGenError::Other(format!(
778 "Failed to parse {error_label}: {e}. Content: {}",
779 response_snippet(&candidate, 500)
780 ))
781 })
782}
783
784fn normalize_plain_text_content(content: &str) -> String {
785 let trimmed = content.trim();
786
787 if let Some(start) = trimmed.find("```") {
788 let after_marker = &trimmed[start + 3..];
789 let content_start = after_marker.find('\n').map_or(0, |i| i + 1);
790 let after_newline = &after_marker[content_start..];
791 if let Some(end) = after_newline.find("```") {
792 return after_newline[..end].trim().to_string();
793 }
794 }
795
796 trimmed.to_string()
797}
798
799fn parse_plain_text_output<T: DeserializeOwned>(
800 tool_name: &str,
801 content: &str,
802) -> Result<Option<T>> {
803 let trimmed = normalize_plain_text_content(content);
804 if trimmed.is_empty() {
805 return Ok(None);
806 }
807
808 let value = match tool_name {
809 "create_commit_summary" => serde_json::json!({ "summary": trimmed }),
810 _ => return Ok(None),
811 };
812
813 serde_json::from_value(value).map(Some).map_err(|e| {
814 CommitGenError::Other(format!(
815 "Failed to parse {tool_name} plain-text fallback: {e}. Content: {}",
816 response_snippet(&trimmed, 500)
817 ))
818 })
819}
820
821fn extract_anthropic_content(
822 response_text: &str,
823 tool_name: &str,
824) -> Result<(Option<serde_json::Value>, String, Option<String>)> {
825 let value: serde_json::Value = serde_json::from_str(response_text).map_err(|e| {
826 CommitGenError::Other(format!(
827 "Failed to parse Anthropic response JSON: {e}. Response body: {}",
828 response_snippet(response_text, 500)
829 ))
830 })?;
831
832 let stop_reason = value
833 .get("stop_reason")
834 .and_then(|v| v.as_str())
835 .map(str::to_string);
836
837 let mut tool_input: Option<serde_json::Value> = None;
838 let mut text_parts = Vec::new();
839
840 if let Some(content) = value.get("content").and_then(|v| v.as_array()) {
841 for item in content {
842 let item_type = item.get("type").and_then(|v| v.as_str()).unwrap_or("");
843 match item_type {
844 "tool_use" => {
845 let name = item.get("name").and_then(|v| v.as_str()).unwrap_or("");
846 if name == tool_name
847 && let Some(input) = item.get("input")
848 {
849 tool_input = Some(input.clone());
850 }
851 },
852 "text" => {
853 if let Some(text) = item.get("text").and_then(|v| v.as_str()) {
854 text_parts.push(text.to_string());
855 }
856 },
857 _ => {},
858 }
859 }
860 }
861
862 Ok((tool_input, text_parts.join("\n"), stop_reason))
863}
864
865fn parse_oneshot_response<T: DeserializeOwned>(
866 mode: ResolvedApiMode,
867 kind: OneShotRequestKind,
868 tool_name: &str,
869 operation: &str,
870 response_text: &str,
871) -> OneShotParseOutcome<T> {
872 match mode {
873 ResolvedApiMode::ChatCompletions => {
874 let api_response: ApiResponse = match serde_json::from_str(response_text) {
875 Ok(response) => response,
876 Err(e) => {
877 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
878 "Failed to parse {operation} response JSON: {e}. Response body: {}",
879 response_snippet(response_text, 500)
880 )));
881 },
882 };
883
884 if api_response.choices.is_empty() {
885 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
886 "API returned empty response for {operation}"
887 )));
888 }
889
890 let message = &api_response.choices[0].message;
891 if let Some(refusal) = &message.refusal {
892 return OneShotParseOutcome::Fatal(CommitGenError::Other(format!(
893 "Model refused {operation}: {refusal}"
894 )));
895 }
896
897 let mut last_error: Option<CommitGenError> = None;
898
899 if let Some(tool_call) = message.tool_calls.first()
900 && tool_call.function.name.ends_with(tool_name)
901 {
902 let args = tool_call.function.arguments.trim();
903 if args.is_empty() {
904 last_error = Some(CommitGenError::Other(format!(
905 "Model returned empty function arguments for {operation}"
906 )));
907 } else {
908 match serde_json::from_str::<T>(args) {
909 Ok(output) => {
910 return OneShotParseOutcome::Success(OneShotResponse {
911 output,
912 source: OneShotSource::ToolCall,
913 text_content: message.content.clone(),
914 stop_reason: None,
915 });
916 },
917 Err(e) => {
918 last_error = Some(CommitGenError::Other(format!(
919 "Failed to parse {operation} tool arguments: {e}. Args: {}",
920 response_snippet(args, 500)
921 )));
922 },
923 }
924 }
925 }
926
927 if let Some(content) = &message.content {
928 if content.trim().is_empty() {
929 return OneShotParseOutcome::Retry;
930 }
931
932 match parse_json_output::<T>(content, &format!("{operation} content JSON")) {
933 Ok(output) => {
934 return OneShotParseOutcome::Success(OneShotResponse {
935 output,
936 source: kind.content_source(),
937 text_content: Some(content.clone()),
938 stop_reason: None,
939 });
940 },
941 Err(err) => match parse_plain_text_output::<T>(tool_name, content) {
942 Ok(Some(output)) => {
943 return OneShotParseOutcome::Success(OneShotResponse {
944 output,
945 source: OneShotSource::PlainTextContent,
946 text_content: Some(content.clone()),
947 stop_reason: None,
948 });
949 },
950 Ok(None) => last_error = Some(err),
951 Err(fallback_err) => last_error = Some(fallback_err),
952 },
953 }
954 }
955
956 OneShotParseOutcome::Fatal(last_error.unwrap_or_else(|| {
957 CommitGenError::Other(format!("No {operation} found in API response"))
958 }))
959 },
960 ResolvedApiMode::AnthropicMessages => {
961 let (tool_input, text_content, stop_reason) =
962 match extract_anthropic_content(response_text, tool_name) {
963 Ok(content) => content,
964 Err(err) => return OneShotParseOutcome::Fatal(err),
965 };
966
967 let mut last_error: Option<CommitGenError> = None;
968
969 if let Some(input) = tool_input {
970 match serde_json::from_value::<T>(input) {
971 Ok(output) => {
972 return OneShotParseOutcome::Success(OneShotResponse {
973 output,
974 source: OneShotSource::ToolCall,
975 text_content: (!text_content.is_empty()).then_some(text_content),
976 stop_reason,
977 });
978 },
979 Err(e) => {
980 last_error = Some(CommitGenError::Other(format!(
981 "Failed to parse {operation} tool input: {e}. Response body: {}",
982 response_snippet(response_text, 500)
983 )));
984 },
985 }
986 }
987
988 if text_content.trim().is_empty() {
989 return OneShotParseOutcome::Retry;
990 }
991
992 match parse_json_output::<T>(&text_content, &format!("{operation} content JSON")) {
993 Ok(output) => OneShotParseOutcome::Success(OneShotResponse {
994 output,
995 source: kind.content_source(),
996 text_content: Some(text_content),
997 stop_reason,
998 }),
999 Err(err) => match parse_plain_text_output::<T>(tool_name, &text_content) {
1000 Ok(Some(output)) => OneShotParseOutcome::Success(OneShotResponse {
1001 output,
1002 source: OneShotSource::PlainTextContent,
1003 text_content: Some(text_content),
1004 stop_reason,
1005 }),
1006 Ok(None) => OneShotParseOutcome::Fatal(last_error.unwrap_or(err)),
1007 Err(fallback_err) => OneShotParseOutcome::Fatal(last_error.unwrap_or(fallback_err)),
1008 },
1009 }
1010 },
1011 }
1012}
1013
1014pub async fn run_oneshot<T>(
1015 config: &CommitConfig,
1016 spec: &OneShotSpec<'_>,
1017) -> Result<OneShotResponse<T>>
1018where
1019 T: DeserializeOwned + Serialize,
1020{
1021 let cache_entry = build_cache_entry(config, spec);
1022 if let Some((cache, key)) = cache_entry.as_ref()
1023 && let Some(stored) = cache.get(key)
1024 && let Ok(output) = serde_json::from_str::<T>(&stored)
1025 {
1026 return Ok(OneShotResponse {
1027 output,
1028 source: OneShotSource::Cache,
1029 text_content: None,
1030 stop_reason: None,
1031 });
1032 }
1033 let response: OneShotResponse<T> = retry_api_call(config, async move || {
1037 let mode = config.resolved_api_mode(spec.model);
1038 let structured_attempt = if should_attempt_structured_output(config, spec.model) {
1039 begin_structured_output_attempt(config, spec.model, mode)
1040 } else {
1041 StructuredOutputAttempt::SkipUnsupported
1042 };
1043
1044 let structured_result = match structured_attempt {
1045 StructuredOutputAttempt::SkipUnsupported => None,
1046 StructuredOutputAttempt::Probe | StructuredOutputAttempt::Supported => {
1047 match send_oneshot_request(config, spec, mode, OneShotRequestKind::StructuredOutput)
1048 .await?
1049 {
1050 OneShotRequestOutcome::Response(response_text) => {
1051 if structured_attempt == StructuredOutputAttempt::Probe {
1052 let _ = update_structured_output_capability(
1053 config,
1054 spec.model,
1055 mode,
1056 Some(StructuredOutputCapability::Supported),
1057 );
1058 }
1059 Some(response_text)
1060 },
1061 OneShotRequestOutcome::Retry => {
1062 if structured_attempt == StructuredOutputAttempt::Probe {
1063 let _ = update_structured_output_capability(config, spec.model, mode, None);
1064 }
1065 return Ok((true, None));
1066 },
1067 OneShotRequestOutcome::FallbackToTool => {
1068 let first_detection = update_structured_output_capability(
1069 config,
1070 spec.model,
1071 mode,
1072 Some(StructuredOutputCapability::Unsupported),
1073 );
1074 if first_detection {
1075 crate::style::warn(&format!(
1076 "Structured outputs unsupported for model {}. Using tool calling for the \
1077 remainder of this run.",
1078 spec.model
1079 ));
1080 }
1081 None
1082 },
1083 }
1084 },
1085 };
1086
1087 if let Some(response_text) = structured_result {
1088 match parse_oneshot_response::<T>(
1089 mode,
1090 OneShotRequestKind::StructuredOutput,
1091 spec.tool_name,
1092 spec.operation,
1093 &response_text,
1094 ) {
1095 OneShotParseOutcome::Success(output) => {
1096 if output.source == OneShotSource::PlainTextContent {
1097 let first_detection = update_structured_output_capability(
1098 config,
1099 spec.model,
1100 mode,
1101 Some(StructuredOutputCapability::Unsupported),
1102 );
1103 if first_detection {
1104 crate::style::warn(&format!(
1105 "Structured outputs unsupported for model {}. Using tool calling for the \
1106 remainder of this run.",
1107 spec.model
1108 ));
1109 }
1110 }
1111 return Ok((false, Some(output)));
1112 },
1113 OneShotParseOutcome::Retry => return Ok((true, None)),
1114 OneShotParseOutcome::Fatal(err) => {
1115 crate::style::warn(&format!(
1116 "Structured output parse failed for {}. Falling back to tool calling: {}",
1117 spec.operation, err
1118 ));
1119 },
1120 }
1121 }
1122
1123 let response_text =
1124 match send_oneshot_request(config, spec, mode, OneShotRequestKind::ToolCalling).await? {
1125 OneShotRequestOutcome::Response(response_text) => response_text,
1126 OneShotRequestOutcome::Retry => return Ok((true, None)),
1127 OneShotRequestOutcome::FallbackToTool => {
1128 return Err(CommitGenError::Other(format!(
1129 "Tool-calling fallback recursively requested for {}",
1130 spec.operation
1131 )));
1132 },
1133 };
1134
1135 match parse_oneshot_response::<T>(
1136 mode,
1137 OneShotRequestKind::ToolCalling,
1138 spec.tool_name,
1139 spec.operation,
1140 &response_text,
1141 ) {
1142 OneShotParseOutcome::Success(output) => Ok((false, Some(output))),
1143 OneShotParseOutcome::Retry => Ok((true, None)),
1144 OneShotParseOutcome::Fatal(err) => Err(err),
1145 }
1146 })
1147 .await?;
1148
1149 if let Some((cache, key)) = cache_entry.as_ref()
1150 && let Ok(payload) = serde_json::to_string(&response.output)
1151 {
1152 cache.put(key, spec.model, spec.operation, &payload);
1153 }
1154
1155 Ok(response)
1156}
1157
1158fn build_cache_entry(
1159 config: &CommitConfig,
1160 spec: &OneShotSpec<'_>,
1161) -> Option<(std::sync::Arc<crate::llm_cache::LlmCache>, String)> {
1162 if !spec.cacheable {
1163 return None;
1164 }
1165 let cache = crate::llm_cache::global()?;
1166 let mode = config.resolved_api_mode(spec.model);
1167 let api_mode = match mode {
1168 ResolvedApiMode::ChatCompletions => "chat-completions",
1169 ResolvedApiMode::AnthropicMessages => "anthropic-messages",
1170 };
1171 let key = crate::llm_cache::compute_key(&crate::llm_cache::CacheMaterial {
1172 operation: spec.operation,
1173 model: spec.model,
1174 tool_name: spec.tool_name,
1175 tool_description: spec.tool_description,
1176 system_prompt: spec.system_prompt,
1177 user_prompt: spec.user_prompt,
1178 schema: spec.schema,
1179 temperature: spec.temperature,
1180 max_tokens: spec.max_tokens,
1181 api_mode,
1182 });
1183 Some((cache, key))
1184}
1185
1186#[derive(Debug, Serialize)]
1187struct Message {
1188 role: String,
1189 content: String,
1190}
1191
1192#[derive(Debug, Serialize, Deserialize)]
1193struct FunctionParameters {
1194 #[serde(rename = "type")]
1195 param_type: String,
1196 properties: serde_json::Value,
1197 required: Vec<String>,
1198}
1199
1200#[derive(Debug, Serialize, Deserialize)]
1201struct Function {
1202 name: String,
1203 description: String,
1204 parameters: FunctionParameters,
1205}
1206
1207#[derive(Debug, Serialize, Deserialize)]
1208struct Tool {
1209 #[serde(rename = "type")]
1210 tool_type: String,
1211 function: Function,
1212}
1213
1214#[derive(Debug, Serialize)]
1215struct ApiRequest {
1216 model: String,
1217 max_tokens: u32,
1218 temperature: f32,
1219 #[serde(skip_serializing_if = "Vec::is_empty")]
1220 tools: Vec<Tool>,
1221 #[serde(skip_serializing_if = "Option::is_none")]
1222 tool_choice: Option<serde_json::Value>,
1223 #[serde(skip_serializing_if = "Option::is_none")]
1224 response_format: Option<serde_json::Value>,
1225 #[serde(skip_serializing_if = "Option::is_none")]
1226 prompt_cache_key: Option<String>,
1227 messages: Vec<Message>,
1228}
1229
1230#[derive(Debug, Serialize)]
1231struct AnthropicRequest {
1232 model: String,
1233 max_tokens: u32,
1234 temperature: f32,
1235 #[serde(skip_serializing_if = "Option::is_none")]
1236 system: Option<Vec<AnthropicContent>>,
1237 #[serde(skip_serializing_if = "Vec::is_empty")]
1238 tools: Vec<AnthropicTool>,
1239 #[serde(skip_serializing_if = "Option::is_none")]
1240 tool_choice: Option<AnthropicToolChoice>,
1241 #[serde(skip_serializing_if = "Option::is_none")]
1242 output_format: Option<serde_json::Value>,
1243 messages: Vec<AnthropicMessage>,
1244}
1245
1246#[derive(Debug, Clone, Serialize)]
1247struct PromptCacheControl {
1248 #[serde(rename = "type")]
1249 control_type: String,
1250}
1251
1252#[derive(Debug, Serialize)]
1253struct AnthropicTool {
1254 name: String,
1255 description: String,
1256 input_schema: serde_json::Value,
1257 #[serde(skip_serializing_if = "Option::is_none")]
1258 cache_control: Option<PromptCacheControl>,
1259}
1260
1261#[derive(Debug, Serialize)]
1262struct AnthropicToolChoice {
1263 #[serde(rename = "type")]
1264 choice_type: String,
1265 name: String,
1266}
1267
1268#[derive(Debug, Serialize)]
1269struct AnthropicMessage {
1270 role: String,
1271 content: Vec<AnthropicContent>,
1272}
1273
1274#[derive(Debug, Clone, Serialize)]
1275struct AnthropicContent {
1276 #[serde(rename = "type")]
1277 content_type: String,
1278 text: String,
1279 #[serde(skip_serializing_if = "Option::is_none")]
1280 cache_control: Option<PromptCacheControl>,
1281}
1282
1283#[derive(Debug, Deserialize)]
1284struct ToolCall {
1285 function: FunctionCall,
1286}
1287
1288#[derive(Debug, Deserialize)]
1289struct FunctionCall {
1290 name: String,
1291 arguments: String,
1292}
1293
1294#[derive(Debug, Deserialize)]
1295struct Choice {
1296 message: ResponseMessage,
1297}
1298
1299#[derive(Debug, Deserialize)]
1300struct ResponseMessage {
1301 #[serde(default)]
1302 tool_calls: Vec<ToolCall>,
1303 #[serde(default)]
1304 content: Option<String>,
1305 #[serde(default)]
1306 refusal: Option<String>,
1307}
1308
1309#[derive(Debug, Deserialize)]
1310struct ApiResponse {
1311 choices: Vec<Choice>,
1312}
1313
1314#[derive(Debug, Clone, Serialize, Deserialize)]
1315struct SummaryOutput {
1316 summary: String,
1317}
1318
1319#[derive(Debug, Clone, Serialize, Deserialize)]
1320struct FastCommitOutput {
1321 #[serde(rename = "type")]
1322 commit_type: String,
1323 #[serde(default)]
1324 scope: Option<String>,
1325 summary: String,
1326 #[serde(default)]
1327 details: Vec<String>,
1328}
1329pub async fn retry_api_call<T>(
1331 config: &CommitConfig,
1332 mut f: impl AsyncFnMut() -> Result<(bool, Option<T>)>,
1333) -> Result<T> {
1334 let mut attempt = 0;
1335
1336 loop {
1337 attempt += 1;
1338
1339 match f().await {
1340 Ok((false, Some(result))) => return Ok(result),
1341 Ok((false, None)) => {
1342 return Err(CommitGenError::Other("API call failed without result".to_string()));
1343 },
1344 Ok((true, _)) if attempt < config.max_retries => {
1345 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1346 eprintln!(
1347 "{}",
1348 crate::style::warning(&format!(
1349 "Retry {}/{} after {}ms...",
1350 attempt, config.max_retries, backoff_ms
1351 ))
1352 );
1353 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1354 },
1355 Ok((true, _last_err)) => {
1356 return Err(CommitGenError::ApiRetryExhausted {
1357 retries: config.max_retries,
1358 source: Box::new(CommitGenError::Other("Max retries exceeded".to_string())),
1359 });
1360 },
1361 Err(e) => {
1362 if attempt < config.max_retries {
1363 let backoff_ms = config.initial_backoff_ms * (1 << (attempt - 1));
1364 eprintln!(
1365 "{}",
1366 crate::style::warning(&format!(
1367 "Error: {} - Retry {}/{} after {}ms...",
1368 e, attempt, config.max_retries, backoff_ms
1369 ))
1370 );
1371 tokio::time::sleep(Duration::from_millis(backoff_ms)).await;
1372 continue;
1373 }
1374 return Err(e);
1375 },
1376 }
1377 }
1378}
1379
1380pub fn format_types_description(config: &CommitConfig) -> String {
1383 use std::fmt::Write;
1384 let mut out = String::from("Check types in order (first match wins):\n\n");
1385
1386 for (name, tc) in &config.types {
1387 let _ = writeln!(out, "**{name}**: {}", tc.description);
1388 if !tc.diff_indicators.is_empty() {
1389 let _ = writeln!(out, " Diff indicators: `{}`", tc.diff_indicators.join("`, `"));
1390 }
1391 if !tc.file_patterns.is_empty() {
1392 let _ = writeln!(out, " File patterns: {}", tc.file_patterns.join(", "));
1393 }
1394 for ex in &tc.examples {
1395 let _ = writeln!(out, " - {ex}");
1396 }
1397 if !tc.hint.is_empty() {
1398 let _ = writeln!(out, " Note: {}", tc.hint);
1399 }
1400 out.push('\n');
1401 }
1402
1403 if !config.classifier_hint.is_empty() {
1404 let _ = writeln!(out, "\n{}", config.classifier_hint);
1405 }
1406
1407 out
1408}
1409
1410pub async fn generate_conventional_analysis<'a>(
1412 stat: &'a str,
1413 diff: &'a str,
1414 model_name: &'a str,
1415 scope_candidates_str: &'a str,
1416 ctx: &AnalysisContext<'a>,
1417 config: &'a CommitConfig,
1418) -> Result<ConventionalAnalysis> {
1419 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1420
1421 let analysis_schema = strict_json_schema(
1422 serde_json::json!({
1423 "type": {
1424 "type": "string",
1425 "enum": type_enum,
1426 "description": "Commit type based on change classification"
1427 },
1428 "scope": {
1429 "type": "string",
1430 "description": "Optional scope (module/component). Omit if unclear or multi-component."
1431 },
1432 "details": {
1433 "type": "array",
1434 "description": "Array of 0-6 detail items with changelog metadata.",
1435 "items": {
1436 "type": "object",
1437 "properties": {
1438 "text": {
1439 "type": "string",
1440 "description": "Detail about change, starting with past-tense verb, ending with period"
1441 },
1442 "changelog_category": {
1443 "type": "string",
1444 "enum": ["Added", "Changed", "Fixed", "Deprecated", "Removed", "Security"],
1445 "description": "Changelog category if user-visible. Omit for internal changes."
1446 },
1447 "user_visible": {
1448 "type": "boolean",
1449 "description": "True if this change affects users/API and should appear in changelog"
1450 }
1451 },
1452 "required": ["text", "user_visible"]
1453 }
1454 },
1455 "issue_refs": {
1456 "type": "array",
1457 "description": "Issue numbers from context (e.g., ['#123', '#456']). Empty if none.",
1458 "items": { "type": "string" }
1459 }
1460 }),
1461 &["type", "details", "issue_refs"],
1462 );
1463
1464 let types_desc = format_types_description(config);
1465 let parts = templates::render_analysis_prompt(&templates::AnalysisParams {
1466 variant: &config.analysis_prompt_variant,
1467 stat,
1468 diff,
1469 scope_candidates: scope_candidates_str,
1470 recent_commits: ctx.recent_commits,
1471 common_scopes: ctx.common_scopes,
1472 types_description: Some(&types_desc),
1473 project_context: ctx.project_context,
1474 })?;
1475
1476 let user_prompt = if let Some(user_ctx) = ctx.user_context {
1477 format!("ADDITIONAL CONTEXT FROM USER:\n{user_ctx}\n\n{}", parts.user)
1478 } else {
1479 parts.user
1480 };
1481
1482 let response = run_oneshot::<ConventionalAnalysis>(config, &OneShotSpec {
1483 operation: "analysis",
1484 model: model_name,
1485 max_tokens: 1000,
1486 temperature: config.temperature,
1487 prompt_family: "analysis",
1488 prompt_variant: &config.analysis_prompt_variant,
1489 system_prompt: &parts.system,
1490 user_prompt: &user_prompt,
1491 tool_name: "create_conventional_analysis",
1492 tool_description: "Analyze changes and classify as conventional commit with type, scope, \
1493 details, and metadata",
1494 schema: &analysis_schema,
1495 debug: Some(OneShotDebug {
1496 dir: ctx.debug_output,
1497 prefix: ctx.debug_prefix,
1498 name: "analysis",
1499 }),
1500 cacheable: true,
1501 })
1502 .await?;
1503
1504 Ok(response.output)
1505}
1506
1507fn strip_type_prefix(summary: &str, commit_type: &str, scope: Option<&str>) -> String {
1512 let scope_part = scope.map(|s| format!("({s})")).unwrap_or_default();
1513 let prefix = format!("{commit_type}{scope_part}: ");
1514
1515 summary
1516 .strip_prefix(&prefix)
1517 .or_else(|| {
1518 let prefix_no_scope = format!("{commit_type}: ");
1520 summary.strip_prefix(&prefix_no_scope)
1521 })
1522 .unwrap_or(summary)
1523 .to_string()
1524}
1525
1526fn validate_summary_quality(
1528 summary: &str,
1529 commit_type: &str,
1530 stat: &str,
1531) -> std::result::Result<(), String> {
1532 use crate::validation::is_past_tense_verb;
1533
1534 let first_word = summary
1535 .split_whitespace()
1536 .next()
1537 .ok_or_else(|| "summary is empty".to_string())?;
1538
1539 let first_word_lower = first_word.to_lowercase();
1540
1541 if !is_past_tense_verb(&first_word_lower) {
1543 return Err(format!(
1544 "must start with past-tense verb (ending in -ed/-d or irregular), got '{first_word}'"
1545 ));
1546 }
1547
1548 if first_word_lower == commit_type {
1550 return Err(format!("repeats commit type '{commit_type}' in summary"));
1551 }
1552
1553 let file_exts: Vec<&str> = stat
1555 .lines()
1556 .filter_map(|line| {
1557 let path = line.split('|').next()?.trim();
1558 std::path::Path::new(path).extension()?.to_str()
1559 })
1560 .collect();
1561
1562 if !file_exts.is_empty() {
1563 let total = file_exts.len();
1564 let md_count = file_exts.iter().filter(|&&e| e == "md").count();
1565
1566 if md_count * 100 / total > 80 && commit_type != "docs" {
1568 crate::style::warn(&format!(
1569 "Type mismatch: {}% .md files but type is '{}' (consider docs type)",
1570 md_count * 100 / total,
1571 commit_type
1572 ));
1573 }
1574
1575 let code_exts = [
1577 "rs", "c", "cpp", "cc", "cxx", "h", "hpp", "hxx", "zig", "nim", "v",
1579 "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",
1592 "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",
1611 ];
1612 let code_count = file_exts
1613 .iter()
1614 .filter(|&&e| code_exts.contains(&e))
1615 .count();
1616 if code_count == 0 && (commit_type == "feat" || commit_type == "fix") {
1617 crate::style::warn(&format!(
1618 "Type mismatch: no code files changed but type is '{commit_type}'"
1619 ));
1620 }
1621 }
1622
1623 Ok(())
1624}
1625
1626#[allow(clippy::too_many_arguments, reason = "summary generation needs debug hooks and context")]
1628pub async fn generate_summary_from_analysis<'a>(
1629 stat: &'a str,
1630 commit_type: &'a str,
1631 scope: Option<&'a str>,
1632 details: &'a [String],
1633 user_context: Option<&'a str>,
1634 config: &'a CommitConfig,
1635 debug_dir: Option<&'a Path>,
1636 debug_prefix: Option<&'a str>,
1637) -> Result<CommitSummary> {
1638 let mut validation_attempt = 0;
1639 let max_validation_retries = 1;
1640 let mut last_failure_reason: Option<String> = None;
1641
1642 loop {
1643 let additional_constraint = if let Some(reason) = &last_failure_reason {
1644 format!("\n\nCRITICAL: Previous attempt failed because {reason}. Correct this.")
1645 } else {
1646 String::new()
1647 };
1648
1649 let bullet_points = details.join("\n");
1650 let details_str = if bullet_points.is_empty() {
1651 "None (no supporting detail points were generated)."
1652 } else {
1653 bullet_points.as_str()
1654 };
1655
1656 let scope_str = scope.unwrap_or("");
1657 let prefix_len =
1658 commit_type.len() + 2 + scope_str.len() + if scope_str.is_empty() { 0 } else { 2 };
1659 let max_summary_len = config.summary_guideline.saturating_sub(prefix_len);
1660
1661 let parts = templates::render_summary_prompt(
1662 &config.summary_prompt_variant,
1663 commit_type,
1664 scope_str,
1665 &max_summary_len.to_string(),
1666 details_str,
1667 stat.trim(),
1668 user_context,
1669 )?;
1670
1671 let user_prompt = format!("{}{additional_constraint}", parts.user);
1672 let summary_schema = strict_json_schema(
1673 serde_json::json!({
1674 "summary": {
1675 "type": "string",
1676 "description": format!(
1677 "Single line summary, target {} chars (hard limit {}), past tense verb first.",
1678 config.summary_guideline,
1679 config.summary_hard_limit
1680 ),
1681 "maxLength": config.summary_hard_limit
1682 }
1683 }),
1684 &["summary"],
1685 );
1686
1687 let response = run_oneshot::<SummaryOutput>(config, &OneShotSpec {
1688 operation: "summary",
1689 model: &config.summary_model,
1690 max_tokens: 200,
1691 temperature: config.temperature,
1692 prompt_family: "summary",
1693 prompt_variant: &config.summary_prompt_variant,
1694 system_prompt: &parts.system,
1695 user_prompt: &user_prompt,
1696 tool_name: "create_commit_summary",
1697 tool_description: "Compose a git commit summary line from detail statements",
1698 schema: &summary_schema,
1699 debug: Some(OneShotDebug {
1700 dir: debug_dir,
1701 prefix: debug_prefix,
1702 name: "summary",
1703 }),
1704 cacheable: true,
1705 })
1706 .await;
1707
1708 match response {
1709 Ok(response) => {
1710 let cleaned = strip_type_prefix(&response.output.summary, commit_type, scope);
1711 let summary = CommitSummary::new(cleaned, config.summary_hard_limit)?;
1712
1713 match validate_summary_quality(summary.as_str(), commit_type, stat) {
1714 Ok(()) => return Ok(summary),
1715 Err(reason) if validation_attempt < max_validation_retries => {
1716 crate::style::warn(&format!(
1717 "Validation failed (attempt {}/{}): {}",
1718 validation_attempt + 1,
1719 max_validation_retries + 1,
1720 reason
1721 ));
1722 last_failure_reason = Some(reason);
1723 validation_attempt += 1;
1724 },
1725 Err(reason) => {
1726 crate::style::warn(&format!(
1727 "Validation failed after {} retries: {}. Using fallback.",
1728 max_validation_retries + 1,
1729 reason
1730 ));
1731 return Ok(fallback_from_details_or_summary(
1732 details,
1733 summary.as_str(),
1734 commit_type,
1735 config,
1736 ));
1737 },
1738 }
1739 },
1740 Err(e) => return Err(e),
1741 }
1742 }
1743}
1744
1745fn fallback_from_details_or_summary(
1747 details: &[String],
1748 invalid_summary: &str,
1749 commit_type: &str,
1750 config: &CommitConfig,
1751) -> CommitSummary {
1752 let candidate = if let Some(first_detail) = details.first() {
1753 let mut cleaned = first_detail.trim().trim_end_matches('.').to_string();
1755
1756 let type_word_variants =
1758 [commit_type, &format!("{commit_type}ed"), &format!("{commit_type}d")];
1759 for variant in &type_word_variants {
1760 if cleaned
1761 .to_lowercase()
1762 .starts_with(&format!("{} ", variant.to_lowercase()))
1763 {
1764 cleaned = cleaned[variant.len()..].trim().to_string();
1765 break;
1766 }
1767 }
1768
1769 cleaned
1770 } else {
1771 let mut cleaned = invalid_summary
1773 .split_whitespace()
1774 .skip(1) .collect::<Vec<_>>()
1776 .join(" ");
1777
1778 if cleaned.is_empty() {
1779 cleaned = fallback_summary("", details, commit_type, config)
1780 .as_str()
1781 .to_string();
1782 }
1783
1784 cleaned
1785 };
1786
1787 let with_verb = if candidate
1789 .split_whitespace()
1790 .next()
1791 .is_some_and(|w| crate::validation::is_past_tense_verb(&w.to_lowercase()))
1792 {
1793 candidate
1794 } else {
1795 let verb = match commit_type {
1796 "feat" => "added",
1797 "fix" => "fixed",
1798 "refactor" => "restructured",
1799 "docs" => "documented",
1800 "test" => "tested",
1801 "perf" => "optimized",
1802 "build" | "ci" | "chore" => "updated",
1803 "style" => "formatted",
1804 "revert" => "reverted",
1805 _ => "changed",
1806 };
1807 format!("{verb} {candidate}")
1808 };
1809
1810 CommitSummary::new(with_verb, config.summary_hard_limit)
1811 .unwrap_or_else(|_| fallback_summary("", details, commit_type, config))
1812}
1813
1814pub fn fallback_summary(
1816 stat: &str,
1817 details: &[String],
1818 commit_type: &str,
1819 config: &CommitConfig,
1820) -> CommitSummary {
1821 let mut candidate = if let Some(first) = details.first() {
1822 first.trim().trim_end_matches('.').to_string()
1823 } else {
1824 let primary_line = stat
1825 .lines()
1826 .map(str::trim)
1827 .find(|line| !line.is_empty())
1828 .unwrap_or("files");
1829
1830 let subject = primary_line
1831 .split('|')
1832 .next()
1833 .map(str::trim)
1834 .filter(|s| !s.is_empty())
1835 .unwrap_or("files");
1836
1837 if subject.eq_ignore_ascii_case("files") {
1838 "Updated files".to_string()
1839 } else {
1840 format!("Updated {subject}")
1841 }
1842 };
1843
1844 candidate = candidate
1845 .replace(['\n', '\r'], " ")
1846 .split_whitespace()
1847 .collect::<Vec<_>>()
1848 .join(" ")
1849 .trim()
1850 .trim_end_matches('.')
1851 .trim_end_matches(';')
1852 .trim_end_matches(':')
1853 .to_string();
1854
1855 if candidate.is_empty() {
1856 candidate = "Updated files".to_string();
1857 }
1858
1859 const CONSERVATIVE_MAX: usize = 50;
1862 while candidate.len() > CONSERVATIVE_MAX {
1863 if let Some(pos) = candidate.rfind(' ') {
1864 candidate.truncate(pos);
1865 candidate = candidate.trim_end_matches(',').trim().to_string();
1866 } else {
1867 candidate.truncate(CONSERVATIVE_MAX);
1868 break;
1869 }
1870 }
1871
1872 candidate = candidate.trim_end_matches('.').to_string();
1874
1875 if candidate
1878 .split_whitespace()
1879 .next()
1880 .is_some_and(|word| word.eq_ignore_ascii_case(commit_type))
1881 {
1882 candidate = match commit_type {
1883 "refactor" => "restructured change".to_string(),
1884 "feat" => "added functionality".to_string(),
1885 "fix" => "fixed issue".to_string(),
1886 "docs" => "documented updates".to_string(),
1887 "test" => "tested changes".to_string(),
1888 "chore" | "build" | "ci" | "style" => "updated tooling".to_string(),
1889 "perf" => "optimized performance".to_string(),
1890 "revert" => "reverted previous commit".to_string(),
1891 _ => "updated files".to_string(),
1892 };
1893 }
1894
1895 CommitSummary::new(candidate, config.summary_hard_limit)
1898 .expect("fallback summary should always be valid")
1899}
1900
1901pub async fn generate_analysis_with_map_reduce<'a>(
1906 stat: &'a str,
1907 diff: &'a str,
1908 model_name: &'a str,
1909 scope_candidates_str: &'a str,
1910 ctx: &AnalysisContext<'a>,
1911 config: &'a CommitConfig,
1912 counter: &TokenCounter,
1913) -> Result<ConventionalAnalysis> {
1914 use crate::map_reduce::{run_map_reduce, should_use_map_reduce};
1915
1916 if should_use_map_reduce(diff, config, counter) {
1917 crate::style::print_info(&format!(
1918 "Large diff detected ({} tokens), using map-reduce...",
1919 counter.count_sync(diff)
1920 ));
1921 run_map_reduce(diff, stat, scope_candidates_str, model_name, config, counter).await
1922 } else {
1923 generate_conventional_analysis(stat, diff, model_name, scope_candidates_str, ctx, config)
1924 .await
1925 }
1926}
1927
1928pub async fn generate_fast_commit(
1932 stat: &str,
1933 diff: &str,
1934 model_name: &str,
1935 scope_candidates_str: &str,
1936 user_context: Option<&str>,
1937 config: &CommitConfig,
1938 debug_dir: Option<&Path>,
1939) -> Result<ConventionalCommit> {
1940 let type_enum: Vec<&str> = config.types.keys().map(|s| s.as_str()).collect();
1941
1942 let parts = templates::render_fast_prompt(&templates::FastPromptParams {
1943 variant: "default",
1944 stat,
1945 diff,
1946 scope_candidates: scope_candidates_str,
1947 user_context,
1948 })?;
1949
1950 let fast_schema = strict_json_schema(
1951 serde_json::json!({
1952 "type": {
1953 "type": "string",
1954 "enum": type_enum,
1955 "description": "Conventional commit type"
1956 },
1957 "scope": {
1958 "type": "string",
1959 "description": "Optional scope. Omit if unclear or cross-cutting."
1960 },
1961 "summary": {
1962 "type": "string",
1963 "description": "≤72 char past-tense summary, no type prefix, no trailing period"
1964 },
1965 "details": {
1966 "type": "array",
1967 "items": { "type": "string" },
1968 "description": "0-3 past-tense detail sentences ending with period"
1969 }
1970 }),
1971 &["type", "summary", "details"],
1972 );
1973
1974 let response = run_oneshot::<FastCommitOutput>(config, &OneShotSpec {
1975 operation: "fast",
1976 model: model_name,
1977 max_tokens: 500,
1978 temperature: config.temperature,
1979 prompt_family: "fast",
1980 prompt_variant: "default",
1981 system_prompt: &parts.system,
1982 user_prompt: &parts.user,
1983 tool_name: "create_fast_commit",
1984 tool_description: "Generate a conventional commit from the given diff",
1985 schema: &fast_schema,
1986 debug: Some(OneShotDebug { dir: debug_dir, prefix: None, name: "fast" }),
1987 cacheable: true,
1988 })
1989 .await?;
1990
1991 build_fast_commit(response.output, config)
1992}
1993
1994fn build_fast_commit(
1996 output: FastCommitOutput,
1997 config: &CommitConfig,
1998) -> Result<ConventionalCommit> {
1999 let commit_type = CommitType::new(&output.commit_type)?;
2000 let scope = coerce_optional_scope(output.scope.as_deref());
2001 let summary = CommitSummary::new(&output.summary, config.summary_hard_limit)?;
2002 Ok(ConventionalCommit { commit_type, scope, summary, body: output.details, footers: vec![] })
2003}
2004#[cfg(test)]
2005mod tests {
2006 use super::*;
2007 use crate::config::CommitConfig;
2008
2009 #[test]
2010 fn test_strict_json_schema_disallows_extra_properties() {
2011 let schema =
2012 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2013 assert_eq!(schema["type"], "object");
2014 assert_eq!(schema["required"], serde_json::json!(["summary"]));
2015 assert_eq!(schema["additionalProperties"], serde_json::json!(false));
2016 }
2017
2018 #[test]
2019 fn test_openai_response_format_uses_strict_json_schema() {
2020 let schema =
2021 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2022 let response_format = openai_response_format("commit_summary", schema.clone());
2023
2024 assert_eq!(response_format["type"], "json_schema");
2025 assert_eq!(response_format["json_schema"]["name"], "commit_summary");
2026 assert_eq!(response_format["json_schema"]["strict"], serde_json::json!(true));
2027 assert_eq!(response_format["json_schema"]["schema"], schema);
2028 }
2029
2030 #[test]
2031 fn test_anthropic_output_format_wraps_schema() {
2032 let schema =
2033 strict_json_schema(serde_json::json!({ "summary": { "type": "string" } }), &["summary"]);
2034 let output_format = anthropic_output_format(schema.clone());
2035
2036 assert_eq!(output_format["type"], "json_schema");
2037 assert_eq!(output_format["schema"], schema);
2038 }
2039
2040 #[test]
2041 fn test_extract_json_from_content_code_block() {
2042 let content = r#"Here is the payload:
2043
2044```json
2045{"summary":"added support"}
2046```
2047"#;
2048 assert_eq!(extract_json_from_content(content), r#"{"summary":"added support"}"#);
2049 }
2050
2051 #[test]
2052 fn test_should_fallback_to_tool_for_structured_output_errors() {
2053 assert!(should_fallback_to_tool(
2054 reqwest::StatusCode::BAD_REQUEST,
2055 "Unknown parameter: response_format",
2056 ));
2057 assert!(!should_fallback_to_tool(
2058 reqwest::StatusCode::UNAUTHORIZED,
2059 "Unknown parameter: response_format",
2060 ));
2061 }
2062
2063 #[test]
2064 fn test_build_fast_commit_coerces_invalid_scope_output() {
2065 let commit = build_fast_commit(
2066 FastCommitOutput {
2067 commit_type: "chore".to_string(),
2068 scope: Some(".".to_string()),
2069 summary: "updated tooling".to_string(),
2070 details: vec![],
2071 },
2072 &CommitConfig::default(),
2073 )
2074 .unwrap();
2075
2076 assert!(commit.scope.is_none());
2077 }
2078
2079 #[test]
2080 fn test_build_fast_commit_sanitizes_path_like_scope_output() {
2081 let commit = build_fast_commit(
2082 FastCommitOutput {
2083 commit_type: "chore".to_string(),
2084 scope: Some(".github/Release Notes".to_string()),
2085 summary: "updated tooling".to_string(),
2086 details: vec![],
2087 },
2088 &CommitConfig::default(),
2089 )
2090 .unwrap();
2091
2092 assert_eq!(
2093 commit.scope.as_ref().map(crate::types::Scope::as_str),
2094 Some("github/release-notes")
2095 );
2096 }
2097
2098 #[test]
2099 fn test_is_anthropic_model_recognizes_common_names() {
2100 assert!(is_anthropic_model("claude-haiku-4-5"));
2101 assert!(is_anthropic_model("anthropic/claude-sonnet-4.5"));
2102 assert!(is_anthropic_model("bedrock/anthropic.claude-3-5-sonnet"));
2103 assert!(!is_anthropic_model("gpt-4o-mini"));
2104 }
2105
2106 #[test]
2107 fn test_should_attempt_structured_output_skips_claude_on_unofficial_base() {
2108 let config = CommitConfig::default();
2109 assert!(!should_attempt_structured_output(&config, "claude-haiku-4-5"));
2110 assert!(should_attempt_structured_output(&config, "gpt-4o-mini"));
2111 }
2112
2113 #[test]
2114 fn test_should_attempt_structured_output_skips_codex_spark_models() {
2115 let config = CommitConfig::default();
2116 assert!(!should_attempt_structured_output(&config, "gpt-5.3-codex-spark"));
2117 assert!(!should_attempt_structured_output(&config, "openai/gpt-5.3-codex-spark",));
2118 }
2119
2120 #[test]
2121 fn test_should_attempt_structured_output_allows_claude_on_official_anthropic_base() {
2122 let config = CommitConfig {
2123 api_base_url: "https://api.anthropic.com/v1".to_string(),
2124 ..CommitConfig::default()
2125 };
2126 assert!(should_attempt_structured_output(&config, "claude-haiku-4-5"));
2127 }
2128
2129 #[test]
2130 fn test_structured_output_capability_cache_skips_after_unsupported() {
2131 let config = CommitConfig::default();
2132 let mode = ResolvedApiMode::ChatCompletions;
2133 let model = "test-structured-skip-after-unsupported";
2134
2135 assert_eq!(
2136 begin_structured_output_attempt(&config, model, mode),
2137 StructuredOutputAttempt::Probe
2138 );
2139 assert!(update_structured_output_capability(
2140 &config,
2141 model,
2142 mode,
2143 Some(StructuredOutputCapability::Unsupported),
2144 ));
2145 assert_eq!(
2146 begin_structured_output_attempt(&config, model, mode),
2147 StructuredOutputAttempt::SkipUnsupported
2148 );
2149 }
2150
2151 #[test]
2152 fn test_structured_output_capability_cache_remembers_supported() {
2153 let config = CommitConfig::default();
2154 let mode = ResolvedApiMode::ChatCompletions;
2155 let model = "test-structured-remembers-supported";
2156
2157 assert_eq!(
2158 begin_structured_output_attempt(&config, model, mode),
2159 StructuredOutputAttempt::Probe
2160 );
2161 assert!(!update_structured_output_capability(
2162 &config,
2163 model,
2164 mode,
2165 Some(StructuredOutputCapability::Supported),
2166 ));
2167 assert_eq!(
2168 begin_structured_output_attempt(&config, model, mode),
2169 StructuredOutputAttempt::Supported
2170 );
2171 }
2172
2173 #[test]
2174 fn test_structured_output_capability_cache_is_mode_scoped() {
2175 let config = CommitConfig::default();
2176 let model = "test-structured-mode-scoped";
2177 assert_eq!(
2178 begin_structured_output_attempt(&config, model, ResolvedApiMode::ChatCompletions,),
2179 StructuredOutputAttempt::Probe
2180 );
2181 assert!(update_structured_output_capability(
2182 &config,
2183 model,
2184 ResolvedApiMode::ChatCompletions,
2185 Some(StructuredOutputCapability::Unsupported),
2186 ));
2187 assert_eq!(
2188 begin_structured_output_attempt(&config, model, ResolvedApiMode::AnthropicMessages,),
2189 StructuredOutputAttempt::Probe
2190 );
2191 }
2192
2193 #[test]
2194 fn test_parse_oneshot_response_prefers_tool_payload() {
2195 let response_text = serde_json::json!({
2196 "choices": [{
2197 "message": {
2198 "tool_calls": [{
2199 "function": {
2200 "name": "create_commit_summary",
2201 "arguments": "{\"summary\":\"added feature\"}"
2202 }
2203 }],
2204 "content": "{\"summary\":\"ignored\"}"
2205 }
2206 }]
2207 })
2208 .to_string();
2209
2210 let result = parse_oneshot_response::<SummaryOutput>(
2211 ResolvedApiMode::ChatCompletions,
2212 OneShotRequestKind::ToolCalling,
2213 "create_commit_summary",
2214 "summary",
2215 &response_text,
2216 );
2217
2218 match result {
2219 OneShotParseOutcome::Success(response) => {
2220 assert_eq!(response.source, OneShotSource::ToolCall);
2221 assert_eq!(response.output.summary, "added feature");
2222 },
2223 OneShotParseOutcome::Retry => panic!("expected parsed tool payload"),
2224 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2225 }
2226 }
2227
2228 #[test]
2229 fn test_parse_oneshot_response_falls_back_to_content_json() {
2230 let response_text = serde_json::json!({
2231 "choices": [{
2232 "message": {
2233 "tool_calls": [{
2234 "function": {
2235 "name": "create_commit_summary",
2236 "arguments": "{invalid json}"
2237 }
2238 }],
2239 "content": "{\"summary\":\"added fallback\"}"
2240 }
2241 }]
2242 })
2243 .to_string();
2244
2245 let result = parse_oneshot_response::<SummaryOutput>(
2246 ResolvedApiMode::ChatCompletions,
2247 OneShotRequestKind::ToolCalling,
2248 "create_commit_summary",
2249 "summary",
2250 &response_text,
2251 );
2252
2253 match result {
2254 OneShotParseOutcome::Success(response) => {
2255 assert_eq!(response.source, OneShotSource::OutputJsonParse);
2256 assert_eq!(response.output.summary, "added fallback");
2257 },
2258 OneShotParseOutcome::Retry => panic!("expected parsed content JSON"),
2259 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2260 }
2261 }
2262
2263 #[test]
2264 fn test_parse_oneshot_response_accepts_plain_text_summary_content() {
2265 let response_text = serde_json::json!({
2266 "choices": [{
2267 "message": {
2268 "content": "updated gemini-image tests for CustomToolContext and array headers"
2269 }
2270 }]
2271 })
2272 .to_string();
2273
2274 let result = parse_oneshot_response::<SummaryOutput>(
2275 ResolvedApiMode::ChatCompletions,
2276 OneShotRequestKind::ToolCalling,
2277 "create_commit_summary",
2278 "summary",
2279 &response_text,
2280 );
2281
2282 match result {
2283 OneShotParseOutcome::Success(response) => {
2284 assert_eq!(response.source, OneShotSource::PlainTextContent);
2285 assert_eq!(
2286 response.output.summary,
2287 "updated gemini-image tests for CustomToolContext and array headers"
2288 );
2289 },
2290 OneShotParseOutcome::Retry => panic!("expected plain-text summary fallback"),
2291 OneShotParseOutcome::Fatal(err) => panic!("unexpected parse failure: {err}"),
2292 }
2293 }
2294
2295 #[test]
2296 fn test_validate_summary_quality_valid() {
2297 let stat = "src/main.rs | 10 +++++++---\n";
2298 assert!(validate_summary_quality("added new feature", "feat", stat).is_ok());
2299 assert!(validate_summary_quality("fixed critical bug", "fix", stat).is_ok());
2300 assert!(validate_summary_quality("restructured module layout", "refactor", stat).is_ok());
2301 }
2302
2303 #[test]
2304 fn test_validate_summary_quality_invalid_verb() {
2305 let stat = "src/main.rs | 10 +++++++---\n";
2306 let result = validate_summary_quality("adding new feature", "feat", stat);
2307 assert!(result.is_err());
2308 assert!(result.unwrap_err().contains("past-tense verb"));
2309 }
2310
2311 #[test]
2312 fn test_validate_summary_quality_type_repetition() {
2313 let stat = "src/main.rs | 10 +++++++---\n";
2314 let result = validate_summary_quality("feat new feature", "feat", stat);
2316 assert!(result.is_err());
2317 assert!(result.unwrap_err().contains("past-tense verb"));
2318
2319 let result = validate_summary_quality("fix bug", "fix", stat);
2321 assert!(result.is_err());
2322 assert!(result.unwrap_err().contains("past-tense verb"));
2324 }
2325
2326 #[test]
2327 fn test_validate_summary_quality_empty() {
2328 let stat = "src/main.rs | 10 +++++++---\n";
2329 let result = validate_summary_quality("", "feat", stat);
2330 assert!(result.is_err());
2331 assert!(result.unwrap_err().contains("empty"));
2332 }
2333
2334 #[test]
2335 fn test_validate_summary_quality_markdown_type_mismatch() {
2336 let stat = "README.md | 10 +++++++---\nDOCS.md | 5 +++++\n";
2337 assert!(validate_summary_quality("added documentation", "feat", stat).is_ok());
2339 }
2340
2341 #[test]
2342 fn test_validate_summary_quality_no_code_files() {
2343 let stat = "config.toml | 2 +-\nREADME.md | 1 +\n";
2344 assert!(validate_summary_quality("added config option", "feat", stat).is_ok());
2346 }
2347
2348 #[test]
2349 fn test_fallback_from_details_with_first_detail() {
2350 let config = CommitConfig::default();
2351 let details = vec![
2352 "Added authentication middleware.".to_string(),
2353 "Updated error handling.".to_string(),
2354 ];
2355 let result = fallback_from_details_or_summary(&details, "invalid verb", "feat", &config);
2356 assert_eq!(result.as_str(), "Added authentication middleware");
2358 }
2359
2360 #[test]
2361 fn test_fallback_from_details_strips_type_word() {
2362 let config = CommitConfig::default();
2363 let details = vec!["Featuring new oauth flow.".to_string()];
2364 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2365 assert!(result.as_str().starts_with("added"));
2368 }
2369
2370 #[test]
2371 fn test_fallback_from_details_no_details() {
2372 let config = CommitConfig::default();
2373 let details: Vec<String> = vec![];
2374 let result = fallback_from_details_or_summary(&details, "invalid verb here", "feat", &config);
2375 assert!(result.as_str().starts_with("added"));
2377 }
2378
2379 #[test]
2380 fn test_fallback_from_details_adds_verb() {
2381 let config = CommitConfig::default();
2382 let details = vec!["configuration for oauth".to_string()];
2383 let result = fallback_from_details_or_summary(&details, "invalid", "feat", &config);
2384 assert_eq!(result.as_str(), "added configuration for oauth");
2385 }
2386
2387 #[test]
2388 fn test_fallback_from_details_preserves_existing_verb() {
2389 let config = CommitConfig::default();
2390 let details = vec!["fixed authentication bug".to_string()];
2391 let result = fallback_from_details_or_summary(&details, "invalid", "fix", &config);
2392 assert_eq!(result.as_str(), "fixed authentication bug");
2393 }
2394
2395 #[test]
2396 fn test_fallback_from_details_type_specific_verbs() {
2397 let config = CommitConfig::default();
2398 let details = vec!["module structure".to_string()];
2399
2400 let result = fallback_from_details_or_summary(&details, "invalid", "refactor", &config);
2401 assert_eq!(result.as_str(), "restructured module structure");
2402
2403 let result = fallback_from_details_or_summary(&details, "invalid", "docs", &config);
2404 assert_eq!(result.as_str(), "documented module structure");
2405
2406 let result = fallback_from_details_or_summary(&details, "invalid", "test", &config);
2407 assert_eq!(result.as_str(), "tested module structure");
2408
2409 let result = fallback_from_details_or_summary(&details, "invalid", "perf", &config);
2410 assert_eq!(result.as_str(), "optimized module structure");
2411 }
2412
2413 #[test]
2414 fn test_fallback_summary_with_stat() {
2415 let config = CommitConfig::default();
2416 let stat = "src/main.rs | 10 +++++++---\n";
2417 let details = vec![];
2418 let result = fallback_summary(stat, &details, "feat", &config);
2419 assert!(result.as_str().contains("main.rs") || result.as_str().contains("updated"));
2420 }
2421
2422 #[test]
2423 fn test_fallback_summary_with_details() {
2424 let config = CommitConfig::default();
2425 let stat = "";
2426 let details = vec!["First detail here.".to_string()];
2427 let result = fallback_summary(stat, &details, "feat", &config);
2428 assert_eq!(result.as_str(), "First detail here");
2430 }
2431
2432 #[test]
2433 fn test_fallback_summary_no_stat_no_details() {
2434 let config = CommitConfig::default();
2435 let result = fallback_summary("", &[], "feat", &config);
2436 assert_eq!(result.as_str(), "Updated files");
2438 }
2439
2440 #[test]
2441 fn test_fallback_summary_type_word_overlap() {
2442 let config = CommitConfig::default();
2443 let details = vec!["refactor was performed".to_string()];
2444 let result = fallback_summary("", &details, "refactor", &config);
2445 assert_eq!(result.as_str(), "restructured change");
2447 }
2448
2449 #[test]
2450 fn test_fallback_summary_length_limit() {
2451 let config = CommitConfig::default();
2452 let long_detail = "a ".repeat(100); let details = vec![long_detail.trim().to_string()];
2454 let result = fallback_summary("", &details, "feat", &config);
2455 assert!(result.len() <= 50);
2457 }
2458}