1use super::commit_logging::{
14 AttemptOutcome, CommitAttemptLog, CommitLogSession, ExtractionAttempt, ValidationCheck,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentErrorKind, AgentRegistry, AgentRole};
18use crate::files::llm_output_extraction::{
19 detect_agent_errors_in_output, extract_llm_output, generate_fallback_commit_message,
20 preprocess_raw_content, try_extract_structured_commit_with_trace,
21 try_extract_xml_commit_with_trace, try_salvage_commit_message, validate_commit_message,
22 validate_commit_message_with_report, CommitExtractionResult, OutputFormat,
23};
24use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
25use crate::logger::Logger;
26use crate::pipeline::{run_with_fallback, PipelineRuntime};
27use crate::prompts::{
28 prompt_emergency_commit_with_context, prompt_emergency_no_diff_commit_with_context,
29 prompt_file_list_only_commit_with_context, prompt_file_list_summary_only_commit_with_context,
30 prompt_generate_commit_message_with_diff_with_context,
31 prompt_strict_json_commit_v2_with_context, prompt_strict_json_commit_with_context,
32 prompt_ultra_minimal_commit_v2_with_context, prompt_ultra_minimal_commit_with_context,
33};
34use std::fmt;
35use std::fs::{self, File};
36use std::io::Read;
37use std::str::FromStr;
38
39fn preview_commit_message(msg: &str) -> String {
41 let first_line = msg.lines().next().unwrap_or(msg);
42 if first_line.len() > 60 {
43 format!("{}...", &first_line[..60])
44 } else {
45 first_line.to_string()
46 }
47}
48
49const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
60
61const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
72
73fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
86 let agent_lower = commit_agent.to_lowercase();
87
88 if agent_lower.contains("glm")
90 || agent_lower.contains("zhipuai")
91 || agent_lower.contains("zai")
92 || agent_lower.contains("qwen")
93 || agent_lower.contains("deepseek")
94 {
95 100_000 } else if agent_lower.contains("claude")
97 || agent_lower.contains("ccs")
98 || agent_lower.contains("anthropic")
99 {
100 300_000 } else {
102 MAX_SAFE_PROMPT_SIZE }
104}
105
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum CommitRetryStrategy {
113 Initial,
115 StrictJson,
117 StrictJsonV2,
119 UltraMinimal,
121 UltraMinimalV2,
123 FileListOnly,
125 FileListSummaryOnly,
127 Emergency,
129 EmergencyNoDiff,
131}
132
133impl CommitRetryStrategy {
134 const fn description(self) -> &'static str {
136 match self {
137 Self::Initial => "initial prompt",
138 Self::StrictJson => "strict JSON prompt",
139 Self::StrictJsonV2 => "strict JSON V2 prompt",
140 Self::UltraMinimal => "ultra-minimal prompt",
141 Self::UltraMinimalV2 => "ultra-minimal V2 prompt",
142 Self::FileListOnly => "file list only prompt",
143 Self::FileListSummaryOnly => "file list summary only prompt",
144 Self::Emergency => "emergency prompt",
145 Self::EmergencyNoDiff => "emergency no-diff prompt",
146 }
147 }
148
149 const fn next(self) -> Option<Self> {
151 match self {
152 Self::Initial => Some(Self::StrictJson),
153 Self::StrictJson => Some(Self::StrictJsonV2),
154 Self::StrictJsonV2 => Some(Self::UltraMinimal),
155 Self::UltraMinimal => Some(Self::UltraMinimalV2),
156 Self::UltraMinimalV2 => Some(Self::FileListOnly),
157 Self::FileListOnly => Some(Self::FileListSummaryOnly),
158 Self::FileListSummaryOnly => Some(Self::Emergency),
159 Self::Emergency => Some(Self::EmergencyNoDiff),
160 Self::EmergencyNoDiff => None,
161 }
162 }
163
164 const fn stage_number(self) -> usize {
166 match self {
167 Self::Initial => 1,
168 Self::StrictJson => 2,
169 Self::StrictJsonV2 => 3,
170 Self::UltraMinimal => 4,
171 Self::UltraMinimalV2 => 5,
172 Self::FileListOnly => 6,
173 Self::FileListSummaryOnly => 7,
174 Self::Emergency => 8,
175 Self::EmergencyNoDiff => 9,
176 }
177 }
178
179 const fn total_stages() -> usize {
181 9 }
183}
184
185impl fmt::Display for CommitRetryStrategy {
186 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
187 write!(f, "{}", self.description())
188 }
189}
190
191pub struct CommitMessageResult {
193 pub message: String,
195 pub success: bool,
197 pub _log_path: String,
199}
200
201fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
214 if diff.len() <= max_size {
215 return diff.to_string();
216 }
217
218 let mut files: Vec<DiffFile> = Vec::new();
220 let mut current_file = DiffFile::default();
221 let mut in_file = false;
222
223 for line in diff.lines() {
224 if line.starts_with("diff --git ") {
225 if in_file && !current_file.lines.is_empty() {
227 files.push(std::mem::take(&mut current_file));
228 }
229 in_file = true;
230 current_file.lines.push(line.to_string());
231
232 if let Some(path) = line.split(" b/").nth(1) {
234 current_file.path = path.to_string();
235 current_file.priority = prioritize_file_path(path);
236 }
237 } else if in_file {
238 current_file.lines.push(line.to_string());
239 }
240 }
241
242 if in_file && !current_file.lines.is_empty() {
244 files.push(current_file);
245 }
246
247 let total_files = files.len();
248
249 files.sort_by_key(|f| std::cmp::Reverse(f.priority));
251
252 let mut selected_files = Vec::new();
254 let mut current_size = 0;
255
256 for file in files {
257 let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); if current_size + file_size <= max_size {
260 current_size += file_size;
261 selected_files.push(file);
262 } else if current_size > 0 {
263 break;
266 } else {
267 let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
270 selected_files.push(DiffFile {
271 path: file.path,
272 priority: file.priority,
273 lines: truncated_lines,
274 });
275 break;
276 }
277 }
278
279 let selected_count = selected_files.len();
280 let omitted_count = total_files.saturating_sub(selected_count);
281
282 let mut result = String::new();
284
285 if omitted_count > 0 {
287 use std::fmt::Write;
288 let _ = write!(
289 result,
290 "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
291 );
292 }
293
294 for file in selected_files {
295 for line in &file.lines {
296 result.push_str(line);
297 result.push('\n');
298 }
299 }
300
301 result
302}
303
304#[derive(Debug, Default, Clone)]
306struct DiffFile {
307 path: String,
309 priority: i32,
311 lines: Vec<String>,
313}
314
315fn prioritize_file_path(path: &str) -> i32 {
325 use std::path::Path;
326 let path_lower = path.to_lowercase();
327
328 let has_ext = |ext: &str| -> bool {
330 Path::new(path)
331 .extension()
332 .and_then(std::ffi::OsStr::to_str)
333 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
334 };
335
336 let has_ext_lower = |ext: &str| -> bool {
338 Path::new(&path_lower)
339 .extension()
340 .and_then(std::ffi::OsStr::to_str)
341 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
342 };
343
344 if path_lower.contains("src/") && has_ext_lower("rs") {
346 100
347 } else if path_lower.contains("src/") {
348 80
349 }
350 else if path_lower.contains("test") {
352 40
353 }
354 else if has_ext("toml")
356 || has_ext("json")
357 || path_lower.ends_with("cargo.toml")
358 || path_lower.ends_with("package.json")
359 || path_lower.ends_with("tsconfig.json")
360 {
361 60
362 }
363 else if path_lower.contains("doc") || has_ext("md") {
365 20
366 }
367 else {
369 50
370 }
371}
372
373fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
378 let mut result = Vec::new();
379 let mut current_size = 0;
380
381 for line in lines {
382 let line_size = line.len() + 1; if current_size + line_size <= max_size {
384 current_size += line_size;
385 result.push(line.clone());
386 } else {
387 break;
388 }
389 }
390
391 if let Some(last) = result.last_mut() {
393 last.push_str(" [truncated...]");
394 }
395
396 result
397}
398
399fn check_and_pre_truncate_diff(
403 diff: &str,
404 commit_agent: &str,
405 runtime: &PipelineRuntime,
406) -> (String, bool) {
407 let max_size = max_prompt_size_for_agent(commit_agent);
408 if diff.len() > max_size {
409 runtime.logger.warn(&format!(
410 "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
411 diff.len() / 1024,
412 max_size / 1024
413 ));
414 (truncate_diff_if_large(diff, max_size), true)
415 } else {
416 runtime.logger.info(&format!(
417 "Diff size ({} KB) is within safe limit ({} KB).",
418 diff.len() / 1024,
419 max_size / 1024
420 ));
421 (diff.to_string(), false)
422 }
423}
424
425fn generate_prompt_for_strategy(
427 strategy: CommitRetryStrategy,
428 working_diff: &str,
429 template_context: &crate::prompts::TemplateContext,
430) -> String {
431 match strategy {
432 CommitRetryStrategy::Initial => {
433 prompt_generate_commit_message_with_diff_with_context(template_context, working_diff)
434 }
435 CommitRetryStrategy::StrictJson => {
436 prompt_strict_json_commit_with_context(template_context, working_diff)
437 }
438 CommitRetryStrategy::StrictJsonV2 => {
439 prompt_strict_json_commit_v2_with_context(template_context, working_diff)
440 }
441 CommitRetryStrategy::UltraMinimal => {
442 prompt_ultra_minimal_commit_with_context(template_context, working_diff)
443 }
444 CommitRetryStrategy::UltraMinimalV2 => {
445 prompt_ultra_minimal_commit_v2_with_context(template_context, working_diff)
446 }
447 CommitRetryStrategy::FileListOnly => {
448 prompt_file_list_only_commit_with_context(template_context, working_diff)
449 }
450 CommitRetryStrategy::FileListSummaryOnly => {
451 prompt_file_list_summary_only_commit_with_context(template_context, working_diff)
452 }
453 CommitRetryStrategy::Emergency => {
454 prompt_emergency_commit_with_context(template_context, working_diff)
455 }
456 CommitRetryStrategy::EmergencyNoDiff => {
457 prompt_emergency_no_diff_commit_with_context(template_context, working_diff)
458 }
459 }
460}
461
462fn log_commit_attempt(
464 strategy: CommitRetryStrategy,
465 prompt_size_kb: usize,
466 commit_agent: &str,
467 runtime: &PipelineRuntime,
468) {
469 if strategy == CommitRetryStrategy::Initial {
470 runtime.logger.info(&format!(
471 "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
472 CommitRetryStrategy::total_stages(),
473 strategy,
474 prompt_size_kb,
475 commit_agent
476 ));
477 } else {
478 runtime.logger.warn(&format!(
479 "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
480 strategy as usize + 1,
481 CommitRetryStrategy::total_stages(),
482 strategy,
483 prompt_size_kb,
484 commit_agent
485 ));
486 }
487}
488
489fn handle_commit_extraction_result(
494 extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
495 strategy: CommitRetryStrategy,
496 log_dir: &str,
497 runtime: &PipelineRuntime,
498 last_extraction: &mut Option<CommitExtractionResult>,
499 attempt_log: &mut CommitAttemptLog,
500) -> Option<anyhow::Result<CommitMessageResult>> {
501 let log_file = format!("{log_dir}/final.log");
502
503 match extraction_result {
504 Ok(Some(extraction)) => {
505 let error_kind = extraction.error_kind();
506 if extraction.is_agent_error() {
507 let error_desc = error_kind.map_or("unknown", AgentErrorKind::description);
508
509 if error_kind.is_some_and(AgentErrorKind::is_unrecoverable) {
512 runtime.logger.error(&format!(
513 "Unrecoverable agent error: {error_desc}. Cannot continue."
514 ));
515 attempt_log.set_outcome(AttemptOutcome::AgentError(format!(
516 "Unrecoverable: {error_desc}"
517 )));
518 *last_extraction = Some(extraction);
519 return Some(Err(anyhow::anyhow!(
520 "Unrecoverable agent error: {error_desc}"
521 )));
522 }
523
524 runtime.logger.warn(&format!(
526 "{error_desc} detected with {}. Trying smaller prompt variant.",
527 strategy.description()
528 ));
529 attempt_log.set_outcome(AttemptOutcome::AgentError(format!(
530 "Recoverable: {error_desc}"
531 )));
532 *last_extraction = Some(extraction);
533 None } else if extraction.is_fallback() {
535 runtime.logger.warn(&format!(
536 "Extraction produced fallback message with {strategy}"
537 ));
538 attempt_log
539 .set_outcome(AttemptOutcome::Fallback(extraction.clone().into_message()));
540 *last_extraction = Some(extraction);
541 None } else {
543 runtime.logger.info(&format!(
544 "Successfully extracted commit message with {strategy}"
545 ));
546 let message = extraction.into_message();
547 attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
548 Some(Ok(CommitMessageResult {
549 message,
550 success: true,
551 _log_path: log_file,
552 }))
553 }
554 }
555 Ok(None) => {
556 runtime.logger.warn(&format!(
557 "No valid commit message extracted with {strategy}, will use fallback"
558 ));
559 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
560 "No valid commit message extracted".to_string(),
561 ));
562 None }
564 Err(e) => {
565 runtime.logger.error(&format!(
566 "Failed to extract commit message with {strategy}: {e}"
567 ));
568 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
569 None }
571 }
572}
573
574fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
579 let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
580 for fb in fallbacks {
581 if *fb != primary_agent && !agents_to_try.contains(fb) {
582 agents_to_try.push(fb);
583 }
584 }
585 agents_to_try
586}
587
588struct CommitAttemptContext<'a> {
590 working_diff: &'a str,
592 log_dir: &'a str,
594 diff_was_truncated: bool,
596 template_context: &'a crate::prompts::TemplateContext,
598}
599
600fn run_commit_attempt_with_agent(
607 strategy: CommitRetryStrategy,
608 ctx: &CommitAttemptContext<'_>,
609 runtime: &mut PipelineRuntime,
610 registry: &AgentRegistry,
611 agent: &str,
612 last_extraction: &mut Option<CommitExtractionResult>,
613 session: &mut CommitLogSession,
614) -> Option<anyhow::Result<CommitMessageResult>> {
615 let prompt = generate_prompt_for_strategy(strategy, ctx.working_diff, ctx.template_context);
616 let prompt_size_kb = prompt.len() / 1024;
617
618 let mut attempt_log = session.new_attempt(agent, strategy.description());
620 attempt_log.set_prompt_size(prompt.len());
621 attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
622
623 log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
624
625 let Some(agent_config) = registry.resolve_config(agent) else {
627 runtime
628 .logger
629 .warn(&format!("Agent '{agent}' not found in registry, skipping"));
630 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
631 "Agent '{agent}' not found in registry"
632 )));
633 let _ = attempt_log.write_to_file(session.run_dir());
634 return None;
635 };
636
637 let cmd_str = agent_config.build_cmd(true, true, false);
639 let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
640
641 let exit_code = match crate::pipeline::run_with_prompt(
643 &crate::pipeline::PromptCommand {
644 label: &format!("generate commit message ({})", strategy.description()),
645 display_name: agent,
646 cmd_str: &cmd_str,
647 prompt: &prompt,
648 logfile: &logfile,
649 parser_type: agent_config.json_parser,
650 env_vars: &agent_config.env_vars,
651 },
652 runtime,
653 ) {
654 Ok(result) => result.exit_code,
655 Err(e) => {
656 runtime.logger.error(&format!("Failed to run agent: {e}"));
657 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
658 "Agent execution failed: {e}"
659 )));
660 let _ = attempt_log.write_to_file(session.run_dir());
661 return None;
662 }
663 };
664
665 if exit_code != 0 {
666 runtime
667 .logger
668 .warn("Commit agent failed, checking logs for partial output...");
669 }
670
671 let extraction_result = extract_commit_message_from_logs_with_trace(
672 ctx.log_dir,
673 ctx.working_diff,
674 agent,
675 runtime.logger,
676 &mut attempt_log,
677 );
678
679 let result = handle_commit_extraction_result(
680 extraction_result,
681 strategy,
682 ctx.log_dir,
683 runtime,
684 last_extraction,
685 &mut attempt_log,
686 );
687
688 if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
690 runtime
691 .logger
692 .warn(&format!("Failed to write attempt log: {e}"));
693 }
694
695 result
696}
697
698fn try_progressive_truncation_recovery(
700 diff: &str,
701 log_dir: &str,
702 log_file: &str,
703 runtime: &mut PipelineRuntime,
704 registry: &AgentRegistry,
705 commit_agent: &str,
706 template_context: &crate::prompts::TemplateContext,
707) -> anyhow::Result<CommitMessageResult> {
708 runtime
709 .logger
710 .warn("TokenExhausted detected: All agents failed due to token limits.");
711 runtime
712 .logger
713 .warn("Attempting progressive diff truncation...");
714
715 let truncation_stages = [
716 (50_000, "50KB"),
717 (25_000, "25KB"),
718 (10_000, "10KB"),
719 (1_000, "file-list-only"),
720 ];
721
722 for (size_kb, label) in truncation_stages {
723 runtime.logger.warn(&format!(
724 "Truncation retry: Trying {} limit ({})...",
725 label,
726 size_kb / 1024
727 ));
728
729 let truncated_diff = truncate_diff_if_large(diff, size_kb);
730 let prompt = prompt_emergency_commit_with_context(template_context, &truncated_diff);
731
732 runtime.logger.info(&format!(
733 "Truncated diff attempt ({}): prompt size {} KB",
734 label,
735 prompt.len() / 1024
736 ));
737
738 let exit_code = run_with_fallback(
739 AgentRole::Commit,
740 &format!("generate commit message (truncated {label})"),
741 &prompt,
742 log_dir,
743 runtime,
744 registry,
745 commit_agent,
746 )?;
747
748 if exit_code == 0 {
749 if let Ok(Some(extraction)) = extract_commit_message_from_logs(
750 log_dir,
751 &truncated_diff,
752 commit_agent,
753 runtime.logger,
754 ) {
755 if extraction.is_agent_error() {
756 runtime.logger.warn(&format!(
757 "{label} truncation still hit token limits, trying smaller size..."
758 ));
759 continue;
760 }
761
762 let message = extraction.into_message();
763 if !message.is_empty() {
764 runtime.logger.info(&format!(
765 "Successfully generated commit message with {label} truncation"
766 ));
767 return Ok(CommitMessageResult {
768 message,
769 success: true,
770 _log_path: log_file.to_string(),
771 });
772 }
773 break;
774 }
775 }
776 }
777
778 try_emergency_no_diff_recovery(
780 diff,
781 log_dir,
782 log_file,
783 runtime,
784 registry,
785 commit_agent,
786 template_context,
787 )
788}
789
790fn try_further_truncation_recovery(
792 diff: &str,
793 log_dir: &str,
794 log_file: &str,
795 runtime: &mut PipelineRuntime,
796 registry: &AgentRegistry,
797 commit_agent: &str,
798 template_context: &crate::prompts::TemplateContext,
799) -> anyhow::Result<CommitMessageResult> {
800 runtime
801 .logger
802 .warn("Already pre-truncated but still hit token limits. Trying further truncation...");
803
804 let further_truncation_stages = [
805 (25_000, "25KB"),
806 (10_000, "10KB"),
807 (1_000, "file-list-only"),
808 ];
809
810 for (size_kb, label) in further_truncation_stages {
811 runtime.logger.warn(&format!(
812 "Further truncation: Trying {} limit ({})...",
813 label,
814 size_kb / 1024
815 ));
816
817 let truncated_diff = truncate_diff_if_large(diff, size_kb);
818 let prompt = prompt_emergency_commit_with_context(template_context, &truncated_diff);
819
820 let exit_code = run_with_fallback(
821 AgentRole::Commit,
822 &format!("generate commit message (further truncated {label})"),
823 &prompt,
824 log_dir,
825 runtime,
826 registry,
827 commit_agent,
828 )?;
829
830 if exit_code == 0 {
831 if let Ok(Some(extraction)) = extract_commit_message_from_logs(
832 log_dir,
833 &truncated_diff,
834 commit_agent,
835 runtime.logger,
836 ) {
837 if extraction.is_agent_error() {
838 continue;
839 }
840 let message = extraction.into_message();
841 if !message.is_empty() {
842 return Ok(CommitMessageResult {
843 message,
844 success: true,
845 _log_path: log_file.to_string(),
846 });
847 }
848 break;
849 }
850 }
851 }
852
853 runtime
854 .logger
855 .warn("All further truncation stages failed. Generating fallback from diff...");
856 let fallback = generate_fallback_commit_message(diff);
857 Ok(CommitMessageResult {
858 message: fallback,
859 success: true,
860 _log_path: log_file.to_string(),
861 })
862}
863
864fn return_hardcoded_fallback(log_file: &str, runtime: &PipelineRuntime) -> CommitMessageResult {
866 runtime.logger.warn("");
867 runtime.logger.warn("All recovery methods failed:");
868 runtime.logger.warn(" - All 9 prompt variants exhausted");
869 runtime
870 .logger
871 .warn(" - All agents in fallback chain exhausted");
872 runtime.logger.warn(" - All truncation stages failed");
873 runtime.logger.warn(" - Emergency prompts failed");
874 runtime.logger.warn("");
875 runtime
876 .logger
877 .warn("Using hardcoded fallback commit message as last resort.");
878 runtime.logger.warn(&format!(
879 "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
880 ));
881 runtime.logger.warn("");
882
883 CommitMessageResult {
884 message: HARDCODED_FALLBACK_COMMIT.to_string(),
885 success: true,
886 _log_path: log_file.to_string(),
887 }
888}
889
890fn try_emergency_no_diff_recovery(
892 diff: &str,
893 log_dir: &str,
894 log_file: &str,
895 runtime: &mut PipelineRuntime,
896 registry: &AgentRegistry,
897 commit_agent: &str,
898 template_context: &crate::prompts::TemplateContext,
899) -> anyhow::Result<CommitMessageResult> {
900 runtime
901 .logger
902 .warn("All truncation stages failed. Trying emergency no-diff prompt...");
903 let working_diff = diff; let no_diff_prompt =
905 prompt_emergency_no_diff_commit_with_context(template_context, working_diff);
906
907 let exit_code = run_with_fallback(
908 AgentRole::Commit,
909 "generate commit message (emergency no-diff)",
910 &no_diff_prompt,
911 log_dir,
912 runtime,
913 registry,
914 commit_agent,
915 )?;
916
917 if exit_code == 0 {
918 if let Ok(Some(extraction)) =
919 extract_commit_message_from_logs(log_dir, working_diff, commit_agent, runtime.logger)
920 {
921 if !extraction.is_agent_error() {
922 let message = extraction.into_message();
923 if !message.is_empty() {
924 return Ok(CommitMessageResult {
925 message,
926 success: true,
927 _log_path: log_file.to_string(),
928 });
929 }
930 }
931 }
932 }
933
934 runtime
936 .logger
937 .warn("Emergency no-diff failed. Generating fallback from diff metadata...");
938 let fallback = generate_fallback_commit_message(diff);
939 Ok(CommitMessageResult {
940 message: fallback,
941 success: true,
942 _log_path: log_file.to_string(),
943 })
944}
945
946pub fn generate_commit_message(
987 diff: &str,
988 registry: &AgentRegistry,
989 runtime: &mut PipelineRuntime,
990 commit_agent: &str,
991 template_context: &crate::prompts::TemplateContext,
992) -> anyhow::Result<CommitMessageResult> {
993 let log_dir = ".agent/logs/commit_generation";
994 let log_file = format!("{log_dir}/final.log");
995
996 fs::create_dir_all(log_dir)?;
997 runtime.logger.info("Generating commit message...");
998
999 let mut session = create_commit_log_session(log_dir, runtime);
1001 let (working_diff, diff_was_pre_truncated) =
1002 check_and_pre_truncate_diff(diff, commit_agent, runtime);
1003
1004 let fallbacks = registry.available_fallbacks(AgentRole::Commit);
1005 let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
1006
1007 let mut last_extraction: Option<CommitExtractionResult> = None;
1008 let mut total_attempts = 0;
1009
1010 let attempt_ctx = CommitAttemptContext {
1011 working_diff: &working_diff,
1012 log_dir,
1013 diff_was_truncated: diff_was_pre_truncated,
1014 template_context,
1015 };
1016
1017 if let Some(result) = try_agents_with_strategies(
1019 &agents_to_try,
1020 &attempt_ctx,
1021 runtime,
1022 registry,
1023 &mut last_extraction,
1024 &mut session,
1025 &mut total_attempts,
1026 ) {
1027 log_completion(runtime, &session, total_attempts, &result);
1028 return result;
1029 }
1030
1031 let fallback_ctx = CommitFallbackContext {
1033 diff,
1034 log_dir,
1035 log_file: &log_file,
1036 commit_agent,
1037 diff_was_pre_truncated,
1038 template_context,
1039 };
1040 handle_commit_fallbacks(
1041 &fallback_ctx,
1042 runtime,
1043 registry,
1044 &session,
1045 total_attempts,
1046 last_extraction.as_ref(),
1047 )
1048}
1049
1050fn create_commit_log_session(log_dir: &str, runtime: &mut PipelineRuntime) -> CommitLogSession {
1052 match CommitLogSession::new(log_dir) {
1053 Ok(s) => {
1054 runtime.logger.info(&format!(
1055 "Commit logs will be written to: {}",
1056 s.run_dir().display()
1057 ));
1058 s
1059 }
1060 Err(e) => {
1061 runtime
1062 .logger
1063 .warn(&format!("Failed to create log session: {e}"));
1064 CommitLogSession::new(log_dir).unwrap_or_else(|_| {
1065 CommitLogSession::new("/tmp/ralph-commit-logs").expect("fallback session")
1066 })
1067 }
1068 }
1069}
1070
1071fn try_agents_with_strategies(
1081 agents: &[&str],
1082 ctx: &CommitAttemptContext<'_>,
1083 runtime: &mut PipelineRuntime,
1084 registry: &AgentRegistry,
1085 last_extraction: &mut Option<CommitExtractionResult>,
1086 session: &mut CommitLogSession,
1087 total_attempts: &mut usize,
1088) -> Option<anyhow::Result<CommitMessageResult>> {
1089 let mut strategy = CommitRetryStrategy::Initial;
1090 loop {
1091 runtime.logger.info(&format!(
1092 "Trying strategy {}/{}: {}",
1093 strategy.stage_number(),
1094 CommitRetryStrategy::total_stages(),
1095 strategy.description()
1096 ));
1097
1098 for (agent_idx, agent) in agents.iter().enumerate() {
1099 runtime.logger.info(&format!(
1100 " - Agent {}/{}: {agent}",
1101 agent_idx + 1,
1102 agents.len()
1103 ));
1104
1105 *total_attempts += 1;
1106 if let Some(result) = run_commit_attempt_with_agent(
1107 strategy,
1108 ctx,
1109 runtime,
1110 registry,
1111 agent,
1112 last_extraction,
1113 session,
1114 ) {
1115 return Some(result);
1116 }
1117 }
1118
1119 runtime.logger.warn(&format!(
1120 "All agents failed for strategy: {}",
1121 strategy.description()
1122 ));
1123
1124 match strategy.next() {
1125 Some(next) => strategy = next,
1126 None => break,
1127 }
1128 }
1129 None
1130}
1131
1132fn log_completion(
1134 runtime: &mut PipelineRuntime,
1135 session: &CommitLogSession,
1136 total_attempts: usize,
1137 result: &anyhow::Result<CommitMessageResult>,
1138) {
1139 if let Ok(ref commit_result) = result {
1140 let _ = session.write_summary(
1141 total_attempts,
1142 &format!(
1143 "SUCCESS: {}",
1144 preview_commit_message(&commit_result.message)
1145 ),
1146 );
1147 }
1148 runtime.logger.info(&format!(
1149 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1150 session.run_dir().display()
1151 ));
1152}
1153
1154struct CommitFallbackContext<'a> {
1156 diff: &'a str,
1157 log_dir: &'a str,
1158 log_file: &'a str,
1159 commit_agent: &'a str,
1160 diff_was_pre_truncated: bool,
1161 template_context: &'a crate::prompts::TemplateContext,
1162}
1163
1164fn handle_commit_fallbacks(
1166 ctx: &CommitFallbackContext<'_>,
1167 runtime: &mut PipelineRuntime,
1168 registry: &AgentRegistry,
1169 session: &CommitLogSession,
1170 total_attempts: usize,
1171 last_extraction: Option<&CommitExtractionResult>,
1172) -> anyhow::Result<CommitMessageResult> {
1173 if let Some(extraction) = last_extraction {
1175 if extraction.is_agent_error() {
1176 return Ok(handle_agent_error_fallback(
1177 ctx.diff,
1178 ctx.log_file,
1179 runtime,
1180 session,
1181 total_attempts,
1182 extraction,
1183 ));
1184 }
1185 return Ok(handle_extraction_fallback(
1186 ctx.log_file,
1187 runtime,
1188 session,
1189 total_attempts,
1190 extraction,
1191 ));
1192 }
1193
1194 let is_token_exhausted = last_extraction
1196 == Some(&CommitExtractionResult::AgentError(
1197 AgentErrorKind::TokenExhausted,
1198 ));
1199
1200 if is_token_exhausted && !ctx.diff_was_pre_truncated {
1201 let _ = session.write_summary(
1202 total_attempts,
1203 "TRUNCATION_RECOVERY: Attempting progressive truncation",
1204 );
1205 runtime.logger.info(&format!(
1206 "Attempting truncation recovery. Logs: {}",
1207 session.run_dir().display()
1208 ));
1209 return try_progressive_truncation_recovery(
1210 ctx.diff,
1211 ctx.log_dir,
1212 ctx.log_file,
1213 runtime,
1214 registry,
1215 ctx.commit_agent,
1216 ctx.template_context,
1217 );
1218 }
1219
1220 if is_token_exhausted && ctx.diff_was_pre_truncated {
1221 let _ = session.write_summary(
1222 total_attempts,
1223 "FURTHER_TRUNCATION: Already truncated, trying smaller",
1224 );
1225 runtime.logger.info(&format!(
1226 "Attempting further truncation. Logs: {}",
1227 session.run_dir().display()
1228 ));
1229 return try_further_truncation_recovery(
1230 ctx.diff,
1231 ctx.log_dir,
1232 ctx.log_file,
1233 runtime,
1234 registry,
1235 ctx.commit_agent,
1236 ctx.template_context,
1237 );
1238 }
1239
1240 let _ = session.write_summary(
1242 total_attempts,
1243 &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1244 );
1245 runtime.logger.info(&format!(
1246 "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1247 session.run_dir().display()
1248 ));
1249 Ok(return_hardcoded_fallback(ctx.log_file, runtime))
1250}
1251
1252fn handle_agent_error_fallback(
1254 diff: &str,
1255 log_file: &str,
1256 runtime: &mut PipelineRuntime,
1257 session: &CommitLogSession,
1258 total_attempts: usize,
1259 extraction: &CommitExtractionResult,
1260) -> CommitMessageResult {
1261 runtime.logger.warn(&format!(
1262 "Agent error ({}) - generating fallback commit message from diff...",
1263 extraction
1264 .error_kind()
1265 .map_or("unknown", AgentErrorKind::description)
1266 ));
1267 let fallback = generate_fallback_commit_message(diff);
1268 let _ = session.write_summary(
1269 total_attempts,
1270 &format!(
1271 "AGENT_ERROR_FALLBACK: {}",
1272 preview_commit_message(&fallback)
1273 ),
1274 );
1275 runtime.logger.info(&format!(
1276 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1277 session.run_dir().display()
1278 ));
1279 CommitMessageResult {
1280 message: fallback,
1281 success: true,
1282 _log_path: log_file.to_string(),
1283 }
1284}
1285
1286fn handle_extraction_fallback(
1288 log_file: &str,
1289 runtime: &mut PipelineRuntime,
1290 session: &CommitLogSession,
1291 total_attempts: usize,
1292 extraction: &CommitExtractionResult,
1293) -> CommitMessageResult {
1294 runtime
1295 .logger
1296 .warn("Using fallback commit message from final attempt");
1297 let message = extraction.clone().into_message();
1298 let _ = session.write_summary(
1299 total_attempts,
1300 &format!("FALLBACK: {}", preview_commit_message(&message)),
1301 );
1302 runtime.logger.info(&format!(
1303 "Commit generation complete after {total_attempts} attempts. Logs: {}",
1304 session.run_dir().display()
1305 ));
1306 CommitMessageResult {
1307 message,
1308 success: true,
1309 _log_path: log_file.to_string(),
1310 }
1311}
1312
1313pub fn commit_with_generated_message(
1330 diff: &str,
1331 commit_agent: &str,
1332 git_user_name: Option<&str>,
1333 git_user_email: Option<&str>,
1334 ctx: &mut PhaseContext<'_>,
1335) -> CommitResultFallback {
1336 let staged = match git_add_all() {
1338 Ok(s) => s,
1339 Err(e) => {
1340 return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1341 }
1342 };
1343
1344 if !staged {
1345 return CommitResultFallback::NoChanges;
1346 }
1347
1348 let mut runtime = PipelineRuntime {
1350 timer: ctx.timer,
1351 logger: ctx.logger,
1352 colors: ctx.colors,
1353 config: ctx.config,
1354 };
1355
1356 let result = match generate_commit_message(
1358 diff,
1359 ctx.registry,
1360 &mut runtime,
1361 commit_agent,
1362 ctx.template_context,
1363 ) {
1364 Ok(r) => r,
1365 Err(e) => {
1366 return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1367 }
1368 };
1369
1370 if !result.success || result.message.trim().is_empty() {
1372 ctx.logger
1374 .warn("Commit generation returned empty message, using hardcoded fallback...");
1375 let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1376 match git_commit(&fallback_message, git_user_name, git_user_email) {
1377 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1378 Ok(None) => CommitResultFallback::NoChanges,
1379 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1380 }
1381 } else {
1382 match git_commit(&result.message, git_user_name, git_user_email) {
1384 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1385 Ok(None) => CommitResultFallback::NoChanges,
1386 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1387 }
1388 }
1389}
1390
1391fn extract_commit_message_from_logs(
1411 log_dir: &str,
1412 diff: &str,
1413 agent_cmd: &str,
1414 logger: &Logger,
1415) -> anyhow::Result<Option<CommitExtractionResult>> {
1416 let log_path = find_most_recent_log(log_dir)?;
1418
1419 let Some(log_file) = log_path else {
1420 logger.warn("No log files found in commit generation directory");
1421 return Ok(None);
1422 };
1423
1424 logger.info(&format!(
1425 "Reading commit message from log: {}",
1426 log_file.display()
1427 ));
1428
1429 let mut content = String::new();
1431 let mut file = File::open(&log_file)?;
1432 file.read_to_string(&mut content)?;
1433
1434 if content.trim().is_empty() {
1435 logger.warn("Log file is empty");
1436 return Ok(None);
1437 }
1438
1439 content = preprocess_raw_content(&content);
1441
1442 if let Some(error_kind) = detect_agent_errors_in_output(&content) {
1444 logger.warn(&format!(
1445 "Detected agent error in output: {}. This should trigger fallback.",
1446 error_kind.description()
1447 ));
1448 return Ok(Some(CommitExtractionResult::AgentError(error_kind)));
1449 }
1450
1451 let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(&content);
1453 logger.info(&format!("XML extraction: {xml_detail}"));
1454
1455 if let Some(message) = xml_result {
1456 logger.info("Successfully extracted commit message from XML format");
1457
1458 let report = validate_commit_message_with_report(&message);
1460 if report.all_passed() {
1461 return Ok(Some(CommitExtractionResult::Extracted(message)));
1462 }
1463 logger.warn(&format!(
1465 "XML extraction succeeded but validation failed: {}",
1466 report
1467 .format_failures()
1468 .as_deref()
1469 .unwrap_or("unknown error")
1470 ));
1471 }
1472
1473 logger.info("XML extraction failed, trying JSON schema extraction...");
1474
1475 let (json_result, json_detail) = try_extract_structured_commit_with_trace(&content);
1477 logger.info(&format!("JSON extraction: {json_detail}"));
1478
1479 if let Some(message) = json_result {
1480 logger.info("Successfully extracted commit message from JSON schema");
1481
1482 let report = validate_commit_message_with_report(&message);
1484 if report.all_passed() {
1485 return Ok(Some(CommitExtractionResult::Extracted(message)));
1486 }
1487 logger.warn(&format!(
1488 "JSON extraction succeeded but validation failed: {}",
1489 report
1490 .format_failures()
1491 .as_deref()
1492 .unwrap_or("unknown error")
1493 ));
1494 }
1495
1496 logger.info("JSON schema extraction failed, falling back to pattern-based extraction");
1497
1498 Ok(try_pattern_extraction_with_recovery(
1500 &content, diff, agent_cmd, logger,
1501 ))
1502}
1503
1504fn validate_and_record_extraction(
1508 message: &str,
1509 method: &'static str,
1510 detail: String,
1511 logger: &Logger,
1512 attempt_log: &mut CommitAttemptLog,
1513) -> Option<CommitExtractionResult> {
1514 let report = validate_commit_message_with_report(message);
1515
1516 let validation_checks: Vec<ValidationCheck> = report
1518 .checks
1519 .iter()
1520 .map(|c| {
1521 if c.passed {
1522 ValidationCheck::pass(c.name)
1523 } else {
1524 ValidationCheck::fail(c.name, c.error.clone().unwrap_or_default())
1525 }
1526 })
1527 .collect();
1528 attempt_log.set_validation_checks(validation_checks);
1529
1530 if report.all_passed() {
1531 attempt_log.add_extraction_attempt(ExtractionAttempt::success(method, detail));
1532 Some(CommitExtractionResult::Extracted(message.to_string()))
1533 } else {
1534 let failure_detail = format!(
1535 "Extracted but validation failed: {}",
1536 report.format_failures().unwrap_or_default()
1537 );
1538 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(method, failure_detail));
1539 logger.warn(&format!(
1540 "{method} extraction succeeded but validation failed: {}",
1541 report
1542 .format_failures()
1543 .as_deref()
1544 .unwrap_or("unknown error")
1545 ));
1546 None
1547 }
1548}
1549
1550use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1552
1553fn write_parsing_trace_with_logging(
1555 parsing_trace: &ParsingTraceLog,
1556 log_dir: &str,
1557 logger: &Logger,
1558) {
1559 if let Err(e) = parsing_trace.write_to_file(std::path::Path::new(log_dir)) {
1560 logger.warn(&format!("Failed to write parsing trace log: {e}"));
1561 }
1562}
1563
1564fn try_xml_extraction_traced(
1567 content: &str,
1568 step_number: &mut usize,
1569 parsing_trace: &mut ParsingTraceLog,
1570 logger: &Logger,
1571 attempt_log: &mut CommitAttemptLog,
1572 log_dir: &str,
1573) -> Option<CommitExtractionResult> {
1574 let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(content);
1575 logger.info(&format!(" ✓ XML extraction: {xml_detail}"));
1576
1577 parsing_trace.add_step(
1578 ParsingTraceStep::new(*step_number, "XML Extraction")
1579 .with_input(&content[..content.len().min(1000)])
1580 .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1581 .with_success(xml_result.is_some())
1582 .with_details(&xml_detail),
1583 );
1584 *step_number += 1;
1585
1586 if let Some(message) = xml_result {
1587 if let Some(result) =
1588 validate_and_record_extraction(&message, "XML", xml_detail, logger, attempt_log)
1589 {
1590 parsing_trace.set_final_message(&message);
1591 write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1592 return Some(result);
1593 }
1594 } else {
1595 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1596 }
1597 logger.info(" ✗ XML extraction failed, trying JSON schema extraction...");
1598 None
1599}
1600
1601fn try_json_extraction_traced(
1604 content: &str,
1605 step_number: &mut usize,
1606 parsing_trace: &mut ParsingTraceLog,
1607 logger: &Logger,
1608 attempt_log: &mut CommitAttemptLog,
1609 log_dir: &str,
1610) -> Option<CommitExtractionResult> {
1611 let (json_result, json_detail) = try_extract_structured_commit_with_trace(content);
1612 logger.info(&format!(" ✓ JSON extraction: {json_detail}"));
1613
1614 parsing_trace.add_step(
1615 ParsingTraceStep::new(*step_number, "JSON Schema Extraction")
1616 .with_input(&content[..content.len().min(1000)])
1617 .with_result(json_result.as_deref().unwrap_or("[No JSON found]"))
1618 .with_success(json_result.is_some())
1619 .with_details(&json_detail),
1620 );
1621 *step_number += 1;
1622
1623 if let Some(message) = json_result {
1624 if let Some(result) =
1625 validate_and_record_extraction(&message, "JSON", json_detail, logger, attempt_log)
1626 {
1627 parsing_trace.set_final_message(&message);
1628 write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1629 return Some(result);
1630 }
1631 } else {
1632 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("JSON", json_detail));
1633 }
1634 logger.info(" ✗ JSON schema extraction failed, falling back to pattern-based extraction");
1635 None
1636}
1637
1638fn extract_commit_message_from_logs_with_trace(
1647 log_dir: &str,
1648 diff: &str,
1649 agent_cmd: &str,
1650 logger: &Logger,
1651 attempt_log: &mut CommitAttemptLog,
1652) -> anyhow::Result<Option<CommitExtractionResult>> {
1653 let mut parsing_trace = ParsingTraceLog::new(
1655 attempt_log.attempt_number,
1656 &attempt_log.agent,
1657 &attempt_log.strategy,
1658 );
1659
1660 let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1662 return Ok(None);
1663 };
1664
1665 parsing_trace.set_raw_output(&content);
1667
1668 if let Some(error_kind) = detect_agent_errors_in_output(&content) {
1670 logger.warn(&format!(
1671 "Detected agent error in output: {}. This should trigger fallback.",
1672 error_kind.description()
1673 ));
1674 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1675 "ErrorDetection",
1676 format!("Agent error detected: {}", error_kind.description()),
1677 ));
1678
1679 parsing_trace.add_step(
1681 ParsingTraceStep::new(1, "Agent Error Detection")
1682 .with_input(&content[..content.len().min(1000)])
1683 .with_success(false)
1684 .with_details(&format!("Agent error: {}", error_kind.description())),
1685 );
1686 parsing_trace.set_final_message("[AGENT ERROR]");
1687 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1688
1689 return Ok(Some(CommitExtractionResult::AgentError(error_kind)));
1690 }
1691
1692 let mut step_number = 1;
1693
1694 if let Some(result) = try_xml_extraction_traced(
1696 &content,
1697 &mut step_number,
1698 &mut parsing_trace,
1699 logger,
1700 attempt_log,
1701 log_dir,
1702 ) {
1703 return Ok(Some(result));
1704 }
1705
1706 if let Some(result) = try_json_extraction_traced(
1708 &content,
1709 &mut step_number,
1710 &mut parsing_trace,
1711 logger,
1712 attempt_log,
1713 log_dir,
1714 ) {
1715 return Ok(Some(result));
1716 }
1717
1718 let pattern_result =
1720 try_pattern_extraction_with_recovery_traced(&content, diff, agent_cmd, logger, attempt_log);
1721
1722 if let Some(ref result) = pattern_result {
1724 if let CommitExtractionResult::Extracted(msg) = result {
1725 parsing_trace.add_step(
1726 ParsingTraceStep::new(step_number, "Pattern-based Extraction")
1727 .with_input(&content[..content.len().min(1000)])
1728 .with_result(msg)
1729 .with_success(true)
1730 .with_details("Successfully extracted via pattern matching"),
1731 );
1732 parsing_trace.set_final_message(msg);
1733 }
1734 } else {
1735 parsing_trace.add_step(
1736 ParsingTraceStep::new(step_number, "Pattern-based Extraction")
1737 .with_input(&content[..content.len().min(1000)])
1738 .with_success(false)
1739 .with_details("Pattern extraction failed or validation failed"),
1740 );
1741 }
1742
1743 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1744 Ok(pattern_result)
1745}
1746
1747fn read_log_content_with_trace(
1749 log_dir: &str,
1750 logger: &Logger,
1751 attempt_log: &mut CommitAttemptLog,
1752) -> anyhow::Result<Option<String>> {
1753 let log_path = find_most_recent_log(log_dir)?;
1754 let Some(log_file) = log_path else {
1755 logger.warn("No log files found in commit generation directory");
1756 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1757 "File",
1758 "No log files found".to_string(),
1759 ));
1760 return Ok(None);
1761 };
1762
1763 logger.info(&format!(
1764 "Reading commit message from log: {}",
1765 log_file.display()
1766 ));
1767
1768 let mut content = String::new();
1769 let mut file = File::open(&log_file)?;
1770 file.read_to_string(&mut content)?;
1771 attempt_log.set_raw_output(&content);
1772
1773 if content.trim().is_empty() {
1774 logger.warn("Log file is empty");
1775 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1776 "File",
1777 "Log file is empty".to_string(),
1778 ));
1779 return Ok(None);
1780 }
1781
1782 Ok(Some(preprocess_raw_content(&content)))
1784}
1785
1786fn try_pattern_extraction_with_recovery_traced(
1788 content: &str,
1789 diff: &str,
1790 agent_cmd: &str,
1791 logger: &Logger,
1792 attempt_log: &mut CommitAttemptLog,
1793) -> Option<CommitExtractionResult> {
1794 let format_hint = detect_format_hint_from_agent(agent_cmd);
1795 let extraction = extract_llm_output(content, format_hint);
1796
1797 logger.info(&format!(
1799 "LLM output extraction: {:?} format, structured={}",
1800 extraction.format, extraction.was_structured
1801 ));
1802
1803 if let Some(warning) = &extraction.warning {
1804 logger.warn(&format!("LLM output extraction warning: {warning}"));
1805 }
1806
1807 let extracted = extraction.content;
1808
1809 match validate_commit_message(&extracted) {
1811 Ok(()) => {
1812 logger.info(" ✓ Successfully extracted and validated commit message");
1813 attempt_log.add_extraction_attempt(ExtractionAttempt::success(
1814 "Pattern",
1815 format!(
1816 "Format: {:?}, structured: {}",
1817 extraction.format, extraction.was_structured
1818 ),
1819 ));
1820 Some(CommitExtractionResult::Extracted(extracted))
1821 }
1822 Err(e) => {
1823 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1824 "Pattern",
1825 format!("Validation failed: {e}"),
1826 ));
1827 try_recovery_layers_traced(content, diff, &e, logger, attempt_log)
1828 }
1829 }
1830}
1831
1832fn try_recovery_layers_traced(
1834 content: &str,
1835 diff: &str,
1836 error: &str,
1837 logger: &Logger,
1838 attempt_log: &mut CommitAttemptLog,
1839) -> Option<CommitExtractionResult> {
1840 logger.warn(&format!("Commit message validation failed: {error}"));
1841
1842 logger.info("Attempting to salvage commit message from output...");
1844 if let Some(salvaged) = try_salvage_commit_message(content) {
1845 logger.info(" ✓ Successfully salvaged commit message");
1846 attempt_log.add_extraction_attempt(ExtractionAttempt::success(
1847 "Salvage",
1848 "Salvaged valid commit from mixed output".to_string(),
1849 ));
1850 return Some(CommitExtractionResult::Salvaged(salvaged));
1851 }
1852 logger.warn(" ✗ Salvage attempt failed");
1853 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1854 "Salvage",
1855 "Could not salvage valid commit from content".to_string(),
1856 ));
1857
1858 logger.info("Generating fallback commit message from diff...");
1860 let fallback = generate_fallback_commit_message(diff);
1861
1862 if validate_commit_message(&fallback).is_ok() {
1864 logger.info(&format!(
1865 " ✓ Generated fallback: {}",
1866 fallback.lines().next().unwrap_or(&fallback)
1867 ));
1868 attempt_log.add_extraction_attempt(ExtractionAttempt::success(
1869 "Fallback",
1870 format!("Generated from diff: {}", preview_commit_message(&fallback)),
1871 ));
1872 return Some(CommitExtractionResult::Fallback(fallback));
1873 }
1874
1875 logger.error("Fallback commit message failed validation - this is a bug");
1876 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1877 "Fallback",
1878 "Generated fallback failed validation (bug!)".to_string(),
1879 ));
1880 None
1881}
1882
1883fn detect_format_hint_from_agent(agent_cmd: &str) -> Option<OutputFormat> {
1885 agent_cmd
1886 .split_whitespace()
1887 .find_map(|tok| {
1888 let tok = tok.to_lowercase();
1889 if tok.contains("codex") {
1890 Some("codex")
1891 } else if tok.contains("claude") || tok.contains("ccs") || tok.contains("qwen") {
1892 Some("claude")
1893 } else if tok.contains("gemini") {
1894 Some("gemini")
1895 } else if tok.contains("opencode") {
1896 Some("opencode")
1897 } else {
1898 None
1899 }
1900 })
1901 .and_then(|s| OutputFormat::from_str(s).ok())
1902}
1903
1904fn try_pattern_extraction_with_recovery(
1906 content: &str,
1907 diff: &str,
1908 agent_cmd: &str,
1909 logger: &Logger,
1910) -> Option<CommitExtractionResult> {
1911 let format_hint = detect_format_hint_from_agent(agent_cmd);
1912 let extraction = extract_llm_output(content, format_hint);
1913
1914 logger.info(&format!(
1916 "LLM output extraction: {:?} format, structured={}",
1917 extraction.format, extraction.was_structured
1918 ));
1919
1920 if let Some(warning) = &extraction.warning {
1921 logger.warn(&format!("LLM output extraction warning: {warning}"));
1922 }
1923
1924 let extracted = extraction.content;
1925
1926 match validate_commit_message(&extracted) {
1928 Ok(()) => {
1929 logger.info("Successfully extracted and validated commit message");
1930 Some(CommitExtractionResult::Extracted(extracted))
1931 }
1932 Err(e) => try_recovery_layers(content, diff, &e, logger),
1933 }
1934}
1935
1936fn try_recovery_layers(
1938 content: &str,
1939 diff: &str,
1940 error: &str,
1941 logger: &Logger,
1942) -> Option<CommitExtractionResult> {
1943 logger.warn(&format!("Commit message validation failed: {error}"));
1944
1945 logger.info("Attempting to salvage commit message from output...");
1947 if let Some(salvaged) = try_salvage_commit_message(content) {
1948 logger.info("Successfully salvaged commit message");
1949 return Some(CommitExtractionResult::Salvaged(salvaged));
1950 }
1951 logger.warn("Salvage attempt failed");
1952
1953 logger.info("Generating fallback commit message from diff...");
1955 let fallback = generate_fallback_commit_message(diff);
1956
1957 if validate_commit_message(&fallback).is_ok() {
1959 logger.info(&format!(
1960 "Generated fallback: {}",
1961 fallback.lines().next().unwrap_or(&fallback)
1962 ));
1963 return Some(CommitExtractionResult::Fallback(fallback));
1964 }
1965
1966 logger.error("Fallback commit message failed validation - this is a bug");
1967 None
1968}
1969
1970fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1987 let path = std::path::PathBuf::from(log_path);
1988
1989 if path.is_dir() {
1991 return find_most_recent_log_with_prefix(&path, "");
1992 }
1993
1994 let parent_dir = match path.parent() {
1996 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1997 _ => std::path::PathBuf::from("."),
1998 };
1999
2000 if !parent_dir.exists() {
2001 return Ok(None);
2002 }
2003
2004 let base_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
2005
2006 find_most_recent_log_with_prefix(&parent_dir, base_name)
2007}
2008
2009fn find_most_recent_log_with_prefix(
2014 dir: &std::path::Path,
2015 prefix: &str,
2016) -> anyhow::Result<Option<std::path::PathBuf>> {
2017 if !dir.exists() {
2018 return Ok(None);
2019 }
2020
2021 let entries = fs::read_dir(dir)?;
2022 let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
2023
2024 for entry in entries.flatten() {
2025 let path = entry.path();
2026
2027 if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
2029 if !file_name.starts_with(prefix)
2030 || path.extension().and_then(|s| s.to_str()) != Some("log")
2031 {
2032 continue;
2033 }
2034 } else {
2035 continue;
2036 }
2037
2038 if let Ok(metadata) = entry.metadata() {
2039 if let Ok(modified) = metadata.modified() {
2040 match &most_recent {
2041 None => {
2042 most_recent = Some((path, modified));
2043 }
2044 Some((_, prev_modified)) if modified > *prev_modified => {
2045 most_recent = Some((path, modified));
2046 }
2047 _ => {}
2048 }
2049 }
2050 }
2051 }
2052
2053 Ok(most_recent.map(|(path, _)| path))
2054}
2055
2056#[cfg(test)]
2057mod tests {
2058 use super::*;
2059
2060 #[test]
2061 fn test_find_most_recent_log() {
2062 let result = find_most_recent_log("/nonexistent/path");
2064 assert!(result.is_ok());
2065 assert!(result.unwrap().is_none());
2066 }
2067
2068 #[test]
2069 fn test_truncate_diff_if_large() {
2070 let large_diff = "a".repeat(100_000);
2071 let truncated = truncate_diff_if_large(&large_diff, 10_000);
2072
2073 assert!(truncated.len() < large_diff.len());
2075 }
2076
2077 #[test]
2078 fn test_truncate_preserves_small_diffs() {
2079 let small_diff = "a".repeat(100);
2080 let truncated = truncate_diff_if_large(&small_diff, 10_000);
2081
2082 assert_eq!(truncated, small_diff);
2084 }
2085
2086 #[test]
2087 fn test_truncate_exactly_at_limit() {
2088 let diff = "a".repeat(10_000);
2089 let truncated = truncate_diff_if_large(&diff, 10_000);
2090
2091 assert_eq!(truncated, diff);
2093 }
2094
2095 #[test]
2096 fn test_truncate_preserves_file_boundaries() {
2097 let diff = "diff --git a/file1.rs b/file1.rs\n\
2098 +line1\n\
2099 +line2\n\
2100 diff --git a/file2.rs b/file2.rs\n\
2101 +line3\n\
2102 +line4\n";
2103 let large_diff = format!("{}{}", diff, "x".repeat(100_000));
2104 let truncated = truncate_diff_if_large(&large_diff, 50);
2105
2106 assert!(truncated.contains("diff --git"));
2108 assert!(truncated.contains("Diff truncated"));
2110 }
2111
2112 #[test]
2113 fn test_prioritize_file_path() {
2114 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
2116 assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
2117
2118 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
2120
2121 assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
2123
2124 assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
2126 }
2127
2128 #[test]
2129 fn test_truncate_keeps_high_priority_files() {
2130 let diff = "diff --git a/README.md b/README.md\n\
2131 +doc change\n\
2132 diff --git a/src/main.rs b/src/main.rs\n\
2133 +important change\n\
2134 diff --git a/tests/test.rs b/tests/test.rs\n\
2135 +test change\n";
2136
2137 let truncated = truncate_diff_if_large(diff, 80);
2139
2140 assert!(truncated.contains("src/main.rs"));
2142 }
2143
2144 #[test]
2145 fn test_truncate_lines_to_fit() {
2146 let lines = vec![
2147 "line1".to_string(),
2148 "line2".to_string(),
2149 "line3".to_string(),
2150 "line4".to_string(),
2151 ];
2152
2153 let truncated = truncate_lines_to_fit(&lines, 18);
2155
2156 assert_eq!(truncated.len(), 3);
2157 assert!(truncated[2].ends_with("[truncated...]"));
2159 }
2160
2161 #[test]
2162 fn test_hardcoded_fallback_commit() {
2163 let result = validate_commit_message(HARDCODED_FALLBACK_COMMIT);
2165 assert!(result.is_ok(), "Hardcoded fallback must pass validation");
2166 assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
2167 assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
2168 }
2169}