1use super::commit_logging::{
14 AttemptOutcome, CommitAttemptLog, CommitLogSession, ExtractionAttempt,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::files::llm_output_extraction::{
19 preprocess_raw_content, try_extract_xml_commit_with_trace, CommitExtractionResult,
20};
21use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
22use crate::logger::Logger;
23use crate::pipeline::PipelineRuntime;
24use crate::prompts::{
25 prompt_generate_commit_message_with_diff_with_context, prompt_simplified_commit_with_context,
26 prompt_xsd_retry_with_context,
27};
28use std::fmt;
29use std::fs::{self, File};
30use std::io::Read;
31
32fn preview_commit_message(msg: &str) -> String {
34 let first_line = msg.lines().next().unwrap_or(msg);
35 if first_line.len() > 60 {
36 format!("{}...", &first_line[..60])
37 } else {
38 first_line.to_string()
39 }
40}
41
42const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
53
54pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
65
66fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
79 let agent_lower = commit_agent.to_lowercase();
80
81 if agent_lower.contains("glm")
83 || agent_lower.contains("zhipuai")
84 || agent_lower.contains("zai")
85 || agent_lower.contains("qwen")
86 || agent_lower.contains("deepseek")
87 {
88 100_000 } else if agent_lower.contains("claude")
90 || agent_lower.contains("ccs")
91 || agent_lower.contains("anthropic")
92 {
93 300_000 } else {
95 MAX_SAFE_PROMPT_SIZE }
97}
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111enum CommitRetryStrategy {
112 Normal,
114 Simplified,
116}
117
118impl CommitRetryStrategy {
119 const fn description(self) -> &'static str {
121 match self {
122 Self::Normal => "normal XML prompt",
123 Self::Simplified => "simplified XML prompt",
124 }
125 }
126
127 const fn next(self) -> Option<Self> {
129 match self {
130 Self::Normal => Some(Self::Simplified),
131 Self::Simplified => None,
132 }
133 }
134
135 const fn stage_number(self) -> usize {
137 match self {
138 Self::Normal => 1,
139 Self::Simplified => 2,
140 }
141 }
142
143 const fn total_stages() -> usize {
145 2 }
147
148 const fn max_session_retries(self) -> usize {
150 match self {
151 Self::Normal => 10, Self::Simplified => 10, }
154 }
155}
156
157impl fmt::Display for CommitRetryStrategy {
158 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
159 write!(f, "{}", self.description())
160 }
161}
162
163pub struct CommitMessageResult {
165 pub message: String,
167 pub success: bool,
169 pub _log_path: String,
171}
172
173fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
186 if diff.len() <= max_size {
187 return diff.to_string();
188 }
189
190 let mut files: Vec<DiffFile> = Vec::new();
192 let mut current_file = DiffFile::default();
193 let mut in_file = false;
194
195 for line in diff.lines() {
196 if line.starts_with("diff --git ") {
197 if in_file && !current_file.lines.is_empty() {
199 files.push(std::mem::take(&mut current_file));
200 }
201 in_file = true;
202 current_file.lines.push(line.to_string());
203
204 if let Some(path) = line.split(" b/").nth(1) {
206 current_file.path = path.to_string();
207 current_file.priority = prioritize_file_path(path);
208 }
209 } else if in_file {
210 current_file.lines.push(line.to_string());
211 }
212 }
213
214 if in_file && !current_file.lines.is_empty() {
216 files.push(current_file);
217 }
218
219 let total_files = files.len();
220
221 files.sort_by_key(|f| std::cmp::Reverse(f.priority));
223
224 let mut selected_files = Vec::new();
226 let mut current_size = 0;
227
228 for file in files {
229 let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); if current_size + file_size <= max_size {
232 current_size += file_size;
233 selected_files.push(file);
234 } else if current_size > 0 {
235 break;
238 } else {
239 let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
242 selected_files.push(DiffFile {
243 path: file.path,
244 priority: file.priority,
245 lines: truncated_lines,
246 });
247 break;
248 }
249 }
250
251 let selected_count = selected_files.len();
252 let omitted_count = total_files.saturating_sub(selected_count);
253
254 let mut result = String::new();
256
257 if omitted_count > 0 {
259 use std::fmt::Write;
260 let _ = write!(
261 result,
262 "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
263 );
264 }
265
266 for file in selected_files {
267 for line in &file.lines {
268 result.push_str(line);
269 result.push('\n');
270 }
271 }
272
273 result
274}
275
276#[derive(Debug, Default, Clone)]
278struct DiffFile {
279 path: String,
281 priority: i32,
283 lines: Vec<String>,
285}
286
287fn prioritize_file_path(path: &str) -> i32 {
297 use std::path::Path;
298 let path_lower = path.to_lowercase();
299
300 let has_ext = |ext: &str| -> bool {
302 Path::new(path)
303 .extension()
304 .and_then(std::ffi::OsStr::to_str)
305 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
306 };
307
308 let has_ext_lower = |ext: &str| -> bool {
310 Path::new(&path_lower)
311 .extension()
312 .and_then(std::ffi::OsStr::to_str)
313 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
314 };
315
316 if path_lower.contains("src/") && has_ext_lower("rs") {
318 100
319 } else if path_lower.contains("src/") {
320 80
321 }
322 else if path_lower.contains("test") {
324 40
325 }
326 else if has_ext("toml")
328 || has_ext("json")
329 || path_lower.ends_with("cargo.toml")
330 || path_lower.ends_with("package.json")
331 || path_lower.ends_with("tsconfig.json")
332 {
333 60
334 }
335 else if path_lower.contains("doc") || has_ext("md") {
337 20
338 }
339 else {
341 50
342 }
343}
344
345fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
350 let mut result = Vec::new();
351 let mut current_size = 0;
352
353 for line in lines {
354 let line_size = line.len() + 1; if current_size + line_size <= max_size {
356 current_size += line_size;
357 result.push(line.clone());
358 } else {
359 break;
360 }
361 }
362
363 if let Some(last) = result.last_mut() {
365 last.push_str(" [truncated...]");
366 }
367
368 result
369}
370
371fn check_and_pre_truncate_diff(
375 diff: &str,
376 commit_agent: &str,
377 runtime: &PipelineRuntime,
378) -> (String, bool) {
379 let max_size = max_prompt_size_for_agent(commit_agent);
380 if diff.len() > max_size {
381 runtime.logger.warn(&format!(
382 "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
383 diff.len() / 1024,
384 max_size / 1024
385 ));
386 (truncate_diff_if_large(diff, max_size), true)
387 } else {
388 runtime.logger.info(&format!(
389 "Diff size ({} KB) is within safe limit ({} KB).",
390 diff.len() / 1024,
391 max_size / 1024
392 ));
393 (diff.to_string(), false)
394 }
395}
396
397fn generate_prompt_for_strategy(
402 strategy: CommitRetryStrategy,
403 working_diff: &str,
404 template_context: &crate::prompts::TemplateContext,
405 xsd_error: Option<&str>,
406) -> String {
407 match strategy {
408 CommitRetryStrategy::Normal => {
409 if let Some(error_msg) = xsd_error {
410 prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
412 } else {
413 prompt_generate_commit_message_with_diff_with_context(
415 template_context,
416 working_diff,
417 )
418 }
419 }
420 CommitRetryStrategy::Simplified => {
421 if let Some(error_msg) = xsd_error {
422 prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
424 } else {
425 prompt_simplified_commit_with_context(template_context, working_diff)
427 }
428 }
429 }
430}
431
432fn log_commit_attempt(
434 strategy: CommitRetryStrategy,
435 prompt_size_kb: usize,
436 commit_agent: &str,
437 runtime: &PipelineRuntime,
438) {
439 if strategy == CommitRetryStrategy::Normal {
440 runtime.logger.info(&format!(
441 "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
442 CommitRetryStrategy::total_stages(),
443 strategy,
444 prompt_size_kb,
445 commit_agent
446 ));
447 } else {
448 runtime.logger.warn(&format!(
449 "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
450 strategy as usize + 1,
451 CommitRetryStrategy::total_stages(),
452 strategy,
453 prompt_size_kb,
454 commit_agent
455 ));
456 }
457}
458
459fn handle_commit_extraction_result(
468 extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
469 strategy: CommitRetryStrategy,
470 log_dir: &str,
471 runtime: &PipelineRuntime,
472 last_extraction: &mut Option<CommitExtractionResult>,
473 attempt_log: &mut CommitAttemptLog,
474) -> Option<anyhow::Result<CommitMessageResult>> {
475 let log_file = format!("{log_dir}/final.log");
476
477 match extraction_result {
478 Ok(Some(extraction)) => {
479 runtime.logger.info(&format!(
481 "Successfully extracted commit message with {strategy}"
482 ));
483 let message = extraction.clone().into_message();
484 attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
485 *last_extraction = Some(extraction);
486 Some(Ok(CommitMessageResult {
487 message,
488 success: true,
489 _log_path: log_file,
490 }))
491 }
492 Ok(None) => {
493 runtime.logger.warn(&format!(
494 "No valid commit message extracted with {strategy}, will try next strategy"
495 ));
496 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
497 "No valid commit message extracted".to_string(),
498 ));
499 None }
501 Err(e) => {
502 runtime.logger.error(&format!(
503 "Failed to extract commit message with {strategy}: {e}"
504 ));
505 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
506 None }
508 }
509}
510
511fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
516 let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
517 for fb in fallbacks {
518 if *fb != primary_agent && !agents_to_try.contains(fb) {
519 agents_to_try.push(fb);
520 }
521 }
522 agents_to_try
523}
524
525struct CommitAttemptContext<'a> {
527 working_diff: &'a str,
529 log_dir: &'a str,
531 diff_was_truncated: bool,
533 template_context: &'a crate::prompts::TemplateContext,
535}
536
537fn run_commit_attempt_with_agent(
544 strategy: CommitRetryStrategy,
545 ctx: &CommitAttemptContext<'_>,
546 runtime: &mut PipelineRuntime,
547 registry: &AgentRegistry,
548 agent: &str,
549 last_extraction: &mut Option<CommitExtractionResult>,
550 session: &mut CommitLogSession,
551) -> Option<anyhow::Result<CommitMessageResult>> {
552 let Some(agent_config) = registry.resolve_config(agent) else {
554 runtime
555 .logger
556 .warn(&format!("Agent '{agent}' not found in registry, skipping"));
557 let mut attempt_log = session.new_attempt(agent, strategy.description());
558 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
559 "Agent '{agent}' not found in registry"
560 )));
561 let _ = attempt_log.write_to_file(session.run_dir());
562 return None;
563 };
564
565 let cmd_str = agent_config.build_cmd(true, true, false);
567 let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
568
569 let max_retries = strategy.max_session_retries();
571 let mut xsd_error: Option<String> = None;
572
573 for retry_num in 0..max_retries {
574 let prompt = generate_prompt_for_strategy(
577 strategy,
578 ctx.working_diff,
579 ctx.template_context,
580 xsd_error.as_deref(),
581 );
582 let prompt_size_kb = prompt.len() / 1024;
583
584 let mut attempt_log = session.new_attempt(agent, strategy.description());
586 attempt_log.set_prompt_size(prompt.len());
587 attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
588
589 if retry_num > 0 {
591 runtime.logger.info(&format!(
592 " In-session retry {}/{} for XSD validation",
593 retry_num,
594 max_retries - 1
595 ));
596 if let Some(ref error) = xsd_error {
597 runtime.logger.info(&format!(" XSD error: {}", error));
598 }
599 } else {
600 log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
601 }
602
603 let exit_code = match crate::pipeline::run_with_prompt(
605 &crate::pipeline::PromptCommand {
606 label: &format!("generate commit message ({})", strategy.description()),
607 display_name: agent,
608 cmd_str: &cmd_str,
609 prompt: &prompt,
610 logfile: &logfile,
611 parser_type: agent_config.json_parser,
612 env_vars: &agent_config.env_vars,
613 },
614 runtime,
615 ) {
616 Ok(result) => result.exit_code,
617 Err(e) => {
618 runtime.logger.error(&format!("Failed to run agent: {e}"));
619 attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
620 "Agent execution failed: {e}"
621 )));
622 let _ = attempt_log.write_to_file(session.run_dir());
623 return None;
624 }
625 };
626
627 if exit_code != 0 {
628 runtime
629 .logger
630 .warn("Commit agent failed, checking logs for partial output...");
631 }
632
633 let extraction_result = extract_commit_message_from_logs_with_trace(
634 ctx.log_dir,
635 ctx.working_diff,
636 agent,
637 runtime.logger,
638 &mut attempt_log,
639 );
640
641 match &extraction_result {
643 Ok(Some(_)) => {
644 let result = handle_commit_extraction_result(
646 extraction_result,
647 strategy,
648 ctx.log_dir,
649 runtime,
650 last_extraction,
651 &mut attempt_log,
652 );
653
654 if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
655 runtime
656 .logger
657 .warn(&format!("Failed to write attempt log: {e}"));
658 }
659
660 return result;
661 }
662 _ => {
663 }
665 };
666
667 let xsd_error_msg = attempt_log
669 .extraction_attempts
670 .iter()
671 .find(|attempt| attempt.detail.contains("XSD validation failed"))
672 .map(|attempt| attempt.detail.clone());
673
674 if let Some(ref error_msg) = xsd_error_msg {
675 runtime
676 .logger
677 .warn(&format!(" XSD validation failed: {}", error_msg));
678
679 if retry_num < max_retries - 1 {
680 let error = error_msg
682 .strip_prefix("XSD validation failed: ")
683 .unwrap_or(error_msg);
684
685 xsd_error = Some(error.to_string());
687
688 attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
690 let _ = attempt_log.write_to_file(session.run_dir());
691
692 continue;
694 } else {
695 runtime
697 .logger
698 .warn(" No more in-session retries remaining");
699 }
700 }
701
702 let result = handle_commit_extraction_result(
704 extraction_result,
705 strategy,
706 ctx.log_dir,
707 runtime,
708 last_extraction,
709 &mut attempt_log,
710 );
711
712 if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
714 runtime
715 .logger
716 .warn(&format!("Failed to write attempt log: {e}"));
717 }
718
719 if result.is_some() {
721 return result;
722 }
723
724 if retry_num >= max_retries - 1 {
726 break;
727 }
728
729 break;
731 }
732
733 None
734}
735
736fn return_hardcoded_fallback(log_file: &str, runtime: &PipelineRuntime) -> CommitMessageResult {
738 runtime.logger.warn("");
739 runtime.logger.warn("All recovery methods failed:");
740 runtime.logger.warn(" - All 9 prompt variants exhausted");
741 runtime
742 .logger
743 .warn(" - All agents in fallback chain exhausted");
744 runtime.logger.warn(" - All truncation stages failed");
745 runtime.logger.warn(" - Emergency prompts failed");
746 runtime.logger.warn("");
747 runtime
748 .logger
749 .warn("Using hardcoded fallback commit message as last resort.");
750 runtime.logger.warn(&format!(
751 "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
752 ));
753 runtime.logger.warn("");
754
755 CommitMessageResult {
756 message: HARDCODED_FALLBACK_COMMIT.to_string(),
757 success: true,
758 _log_path: log_file.to_string(),
759 }
760}
761
762pub fn generate_commit_message(
803 diff: &str,
804 registry: &AgentRegistry,
805 runtime: &mut PipelineRuntime,
806 commit_agent: &str,
807 template_context: &crate::prompts::TemplateContext,
808) -> anyhow::Result<CommitMessageResult> {
809 let log_dir = ".agent/logs/commit_generation";
810 let log_file = format!("{log_dir}/final.log");
811
812 fs::create_dir_all(log_dir)?;
813 runtime.logger.info("Generating commit message...");
814
815 let mut session = create_commit_log_session(log_dir, runtime);
817 let (working_diff, diff_was_pre_truncated) =
818 check_and_pre_truncate_diff(diff, commit_agent, runtime);
819
820 let fallbacks = registry.available_fallbacks(AgentRole::Commit);
821 let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
822
823 let mut last_extraction: Option<CommitExtractionResult> = None;
824 let mut total_attempts = 0;
825
826 let attempt_ctx = CommitAttemptContext {
827 working_diff: &working_diff,
828 log_dir,
829 diff_was_truncated: diff_was_pre_truncated,
830 template_context,
831 };
832
833 if let Some(result) = try_agents_with_strategies(
835 &agents_to_try,
836 &attempt_ctx,
837 runtime,
838 registry,
839 &mut last_extraction,
840 &mut session,
841 &mut total_attempts,
842 ) {
843 log_completion(runtime, &session, total_attempts, &result);
844 return result;
845 }
846
847 let fallback_ctx = CommitFallbackContext {
849 log_file: &log_file,
850 };
851 handle_commit_fallbacks(
852 &fallback_ctx,
853 runtime,
854 &session,
855 total_attempts,
856 last_extraction.as_ref(),
857 )
858}
859
860fn create_commit_log_session(log_dir: &str, runtime: &mut PipelineRuntime) -> CommitLogSession {
862 match CommitLogSession::new(log_dir) {
863 Ok(s) => {
864 runtime.logger.info(&format!(
865 "Commit logs will be written to: {}",
866 s.run_dir().display()
867 ));
868 s
869 }
870 Err(e) => {
871 runtime
872 .logger
873 .warn(&format!("Failed to create log session: {e}"));
874 CommitLogSession::new(log_dir).unwrap_or_else(|_| {
875 CommitLogSession::new("/tmp/ralph-commit-logs").expect("fallback session")
876 })
877 }
878 }
879}
880
881fn try_agents_with_strategies(
891 agents: &[&str],
892 ctx: &CommitAttemptContext<'_>,
893 runtime: &mut PipelineRuntime,
894 registry: &AgentRegistry,
895 last_extraction: &mut Option<CommitExtractionResult>,
896 session: &mut CommitLogSession,
897 total_attempts: &mut usize,
898) -> Option<anyhow::Result<CommitMessageResult>> {
899 let mut strategy = CommitRetryStrategy::Normal;
900 loop {
901 runtime.logger.info(&format!(
902 "Trying strategy {}/{}: {}",
903 strategy.stage_number(),
904 CommitRetryStrategy::total_stages(),
905 strategy.description()
906 ));
907
908 for (agent_idx, agent) in agents.iter().enumerate() {
909 runtime.logger.info(&format!(
910 " - Agent {}/{}: {agent}",
911 agent_idx + 1,
912 agents.len()
913 ));
914
915 *total_attempts += 1;
916 if let Some(result) = run_commit_attempt_with_agent(
917 strategy,
918 ctx,
919 runtime,
920 registry,
921 agent,
922 last_extraction,
923 session,
924 ) {
925 return Some(result);
926 }
927 }
928
929 runtime.logger.warn(&format!(
930 "All agents failed for strategy: {}",
931 strategy.description()
932 ));
933
934 match strategy.next() {
935 Some(next) => strategy = next,
936 None => break,
937 }
938 }
939 None
940}
941
942fn log_completion(
944 runtime: &mut PipelineRuntime,
945 session: &CommitLogSession,
946 total_attempts: usize,
947 result: &anyhow::Result<CommitMessageResult>,
948) {
949 if let Ok(ref commit_result) = result {
950 let _ = session.write_summary(
951 total_attempts,
952 &format!(
953 "SUCCESS: {}",
954 preview_commit_message(&commit_result.message)
955 ),
956 );
957 }
958 runtime.logger.info(&format!(
959 "Commit generation complete after {total_attempts} attempts. Logs: {}",
960 session.run_dir().display()
961 ));
962}
963
964struct CommitFallbackContext<'a> {
966 log_file: &'a str,
967}
968
969fn handle_commit_fallbacks(
975 ctx: &CommitFallbackContext<'_>,
976 runtime: &mut PipelineRuntime,
977 session: &CommitLogSession,
978 total_attempts: usize,
979 last_extraction: Option<&CommitExtractionResult>,
980) -> anyhow::Result<CommitMessageResult> {
981 if let Some(extraction) = last_extraction {
984 let message = extraction.clone().into_message();
985 let _ = session.write_summary(
986 total_attempts,
987 &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
988 );
989 runtime.logger.info(&format!(
990 "Commit generation complete after {total_attempts} attempts. Logs: {}",
991 session.run_dir().display()
992 ));
993 return Ok(CommitMessageResult {
994 message,
995 success: true,
996 _log_path: ctx.log_file.to_string(),
997 });
998 }
999
1000 let _ = session.write_summary(
1002 total_attempts,
1003 &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1004 );
1005 runtime.logger.info(&format!(
1006 "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1007 session.run_dir().display()
1008 ));
1009 Ok(return_hardcoded_fallback(ctx.log_file, runtime))
1010}
1011
1012pub fn commit_with_generated_message(
1029 diff: &str,
1030 commit_agent: &str,
1031 git_user_name: Option<&str>,
1032 git_user_email: Option<&str>,
1033 ctx: &mut PhaseContext<'_>,
1034) -> CommitResultFallback {
1035 let staged = match git_add_all() {
1037 Ok(s) => s,
1038 Err(e) => {
1039 return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1040 }
1041 };
1042
1043 if !staged {
1044 return CommitResultFallback::NoChanges;
1045 }
1046
1047 let mut runtime = PipelineRuntime {
1049 timer: ctx.timer,
1050 logger: ctx.logger,
1051 colors: ctx.colors,
1052 config: ctx.config,
1053 #[cfg(any(test, feature = "test-utils"))]
1054 agent_executor: None,
1055 };
1056
1057 let result = match generate_commit_message(
1059 diff,
1060 ctx.registry,
1061 &mut runtime,
1062 commit_agent,
1063 ctx.template_context,
1064 ) {
1065 Ok(r) => r,
1066 Err(e) => {
1067 return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1068 }
1069 };
1070
1071 if !result.success || result.message.trim().is_empty() {
1073 ctx.logger
1075 .warn("Commit generation returned empty message, using hardcoded fallback...");
1076 let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1077 match git_commit(&fallback_message, git_user_name, git_user_email) {
1078 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1079 Ok(None) => CommitResultFallback::NoChanges,
1080 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1081 }
1082 } else {
1083 match git_commit(&result.message, git_user_name, git_user_email) {
1085 Ok(Some(oid)) => CommitResultFallback::Success(oid),
1086 Ok(None) => CommitResultFallback::NoChanges,
1087 Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1088 }
1089 }
1090}
1091
1092use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1094
1095fn write_parsing_trace_with_logging(
1097 parsing_trace: &ParsingTraceLog,
1098 log_dir: &str,
1099 logger: &Logger,
1100) {
1101 if let Err(e) = parsing_trace.write_to_file(std::path::Path::new(log_dir)) {
1102 logger.warn(&format!("Failed to write parsing trace log: {e}"));
1103 }
1104}
1105
1106fn try_xml_extraction_traced(
1109 content: &str,
1110 step_number: &mut usize,
1111 parsing_trace: &mut ParsingTraceLog,
1112 logger: &Logger,
1113 attempt_log: &mut CommitAttemptLog,
1114 log_dir: &str,
1115) -> Option<CommitExtractionResult> {
1116 let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(content);
1117 logger.info(&format!(" ✓ XML extraction: {xml_detail}"));
1118
1119 parsing_trace.add_step(
1120 ParsingTraceStep::new(*step_number, "XML Extraction")
1121 .with_input(&content[..content.len().min(1000)])
1122 .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1123 .with_success(xml_result.is_some())
1124 .with_details(&xml_detail),
1125 );
1126 *step_number += 1;
1127
1128 if let Some(message) = xml_result {
1129 attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1131 parsing_trace.set_final_message(&message);
1132 write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1133 return Some(CommitExtractionResult::new(message));
1134 }
1135
1136 attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1138 logger.info(" ✗ XML extraction failed");
1139 None
1140}
1141
1142fn extract_commit_message_from_logs_with_trace(
1150 log_dir: &str,
1151 _diff: &str,
1152 _agent_cmd: &str,
1153 logger: &Logger,
1154 attempt_log: &mut CommitAttemptLog,
1155) -> anyhow::Result<Option<CommitExtractionResult>> {
1156 let mut parsing_trace = ParsingTraceLog::new(
1158 attempt_log.attempt_number,
1159 &attempt_log.agent,
1160 &attempt_log.strategy,
1161 );
1162
1163 let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1165 return Ok(None);
1166 };
1167
1168 parsing_trace.set_raw_output(&content);
1170
1171 let mut step_number = 1;
1172
1173 if let Some(result) = try_xml_extraction_traced(
1177 &content,
1178 &mut step_number,
1179 &mut parsing_trace,
1180 logger,
1181 attempt_log,
1182 log_dir,
1183 ) {
1184 return Ok(Some(result));
1185 }
1186
1187 parsing_trace.add_step(
1189 ParsingTraceStep::new(step_number, "XML Extraction Failed")
1190 .with_input(&content[..content.len().min(1000)])
1191 .with_success(false)
1192 .with_details("No valid XML found or XSD validation failed"),
1193 );
1194
1195 write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1196
1197 Ok(None)
1201}
1202
1203fn read_log_content_with_trace(
1205 log_dir: &str,
1206 logger: &Logger,
1207 attempt_log: &mut CommitAttemptLog,
1208) -> anyhow::Result<Option<String>> {
1209 let log_path = find_most_recent_log(log_dir)?;
1210 let Some(log_file) = log_path else {
1211 logger.warn("No log files found in commit generation directory");
1212 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1213 "File",
1214 "No log files found".to_string(),
1215 ));
1216 return Ok(None);
1217 };
1218
1219 logger.info(&format!(
1220 "Reading commit message from log: {}",
1221 log_file.display()
1222 ));
1223
1224 let mut content = String::new();
1225 let mut file = File::open(&log_file)?;
1226 file.read_to_string(&mut content)?;
1227 attempt_log.set_raw_output(&content);
1228
1229 if content.trim().is_empty() {
1230 logger.warn("Log file is empty");
1231 attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1232 "File",
1233 "Log file is empty".to_string(),
1234 ));
1235 return Ok(None);
1236 }
1237
1238 Ok(Some(preprocess_raw_content(&content)))
1240}
1241
1242fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1259 let path = std::path::PathBuf::from(log_path);
1260
1261 if path.is_dir() {
1263 return find_most_recent_log_with_prefix(&path, "");
1264 }
1265
1266 let parent_dir = match path.parent() {
1268 Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1269 _ => std::path::PathBuf::from("."),
1270 };
1271
1272 if !parent_dir.exists() {
1273 return Ok(None);
1274 }
1275
1276 let base_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1277
1278 find_most_recent_log_with_prefix(&parent_dir, base_name)
1279}
1280
1281fn find_most_recent_log_with_prefix(
1286 dir: &std::path::Path,
1287 prefix: &str,
1288) -> anyhow::Result<Option<std::path::PathBuf>> {
1289 if !dir.exists() {
1290 return Ok(None);
1291 }
1292
1293 let entries = fs::read_dir(dir)?;
1294 let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1295
1296 for entry in entries.flatten() {
1297 let path = entry.path();
1298
1299 if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
1301 if !file_name.starts_with(prefix)
1302 || path.extension().and_then(|s| s.to_str()) != Some("log")
1303 {
1304 continue;
1305 }
1306 } else {
1307 continue;
1308 }
1309
1310 if let Ok(metadata) = entry.metadata() {
1311 if let Ok(modified) = metadata.modified() {
1312 match &most_recent {
1313 None => {
1314 most_recent = Some((path, modified));
1315 }
1316 Some((_, prev_modified)) if modified > *prev_modified => {
1317 most_recent = Some((path, modified));
1318 }
1319 _ => {}
1320 }
1321 }
1322 }
1323 }
1324
1325 Ok(most_recent.map(|(path, _)| path))
1326}
1327
1328#[cfg(test)]
1329mod tests {
1330 use super::*;
1331
1332 #[test]
1333 fn test_find_most_recent_log() {
1334 let result = find_most_recent_log("/nonexistent/path");
1336 assert!(result.is_ok());
1337 assert!(result.unwrap().is_none());
1338 }
1339
1340 #[test]
1341 fn test_truncate_diff_if_large() {
1342 let large_diff = "a".repeat(100_000);
1343 let truncated = truncate_diff_if_large(&large_diff, 10_000);
1344
1345 assert!(truncated.len() < large_diff.len());
1347 }
1348
1349 #[test]
1350 fn test_truncate_preserves_small_diffs() {
1351 let small_diff = "a".repeat(100);
1352 let truncated = truncate_diff_if_large(&small_diff, 10_000);
1353
1354 assert_eq!(truncated, small_diff);
1356 }
1357
1358 #[test]
1359 fn test_truncate_exactly_at_limit() {
1360 let diff = "a".repeat(10_000);
1361 let truncated = truncate_diff_if_large(&diff, 10_000);
1362
1363 assert_eq!(truncated, diff);
1365 }
1366
1367 #[test]
1368 fn test_truncate_preserves_file_boundaries() {
1369 let diff = "diff --git a/file1.rs b/file1.rs\n\
1370 +line1\n\
1371 +line2\n\
1372 diff --git a/file2.rs b/file2.rs\n\
1373 +line3\n\
1374 +line4\n";
1375 let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1376 let truncated = truncate_diff_if_large(&large_diff, 50);
1377
1378 assert!(truncated.contains("diff --git"));
1380 assert!(truncated.contains("Diff truncated"));
1382 }
1383
1384 #[test]
1385 fn test_prioritize_file_path() {
1386 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1388 assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1389
1390 assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1392
1393 assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1395
1396 assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1398 }
1399
1400 #[test]
1401 fn test_truncate_keeps_high_priority_files() {
1402 let diff = "diff --git a/README.md b/README.md\n\
1403 +doc change\n\
1404 diff --git a/src/main.rs b/src/main.rs\n\
1405 +important change\n\
1406 diff --git a/tests/test.rs b/tests/test.rs\n\
1407 +test change\n";
1408
1409 let truncated = truncate_diff_if_large(diff, 80);
1411
1412 assert!(truncated.contains("src/main.rs"));
1414 }
1415
1416 #[test]
1417 fn test_truncate_lines_to_fit() {
1418 let lines = vec![
1419 "line1".to_string(),
1420 "line2".to_string(),
1421 "line3".to_string(),
1422 "line4".to_string(),
1423 ];
1424
1425 let truncated = truncate_lines_to_fit(&lines, 18);
1427
1428 assert_eq!(truncated.len(), 3);
1429 assert!(truncated[2].ends_with("[truncated...]"));
1431 }
1432
1433 #[test]
1434 fn test_hardcoded_fallback_commit() {
1435 use crate::files::llm_output_extraction::is_conventional_commit_subject;
1437 assert!(
1438 is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1439 "Hardcoded fallback must be a valid conventional commit"
1440 );
1441 assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1442 assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1443 }
1444}