ralph_workflow/phases/
commit.rs

1//! Commit message generation phase.
2//!
3//! This module handles automated commit message generation using the standard
4//! agent pipeline with fallback support. It replaces the custom implementation
5//! in repo.rs that lacked proper logging and fallback handling.
6//!
7//! The phase:
8//! 1. Takes a git diff as input
9//! 2. Runs the commit agent with the diff via the standard pipeline
10//! 3. Extracts the commit message from agent output
11//! 4. Returns the generated message for use by the caller
12
13use 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
39/// Preview a commit message for display (first line, truncated if needed).
40fn 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
49/// Maximum safe prompt size in bytes before pre-truncation.
50///
51/// This is a conservative limit to prevent agents from failing with "prompt too long"
52/// errors. Different agents have different token limits:
53/// - GLM: ~100KB effective limit
54/// - Claude CCS: ~300KB effective limit
55/// - Others: vary by model
56///
57/// We use 200KB as a safe middle ground that works for most agents while still
58/// allowing substantial diffs to be processed without truncation.
59const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
60
61/// Absolute last resort fallback commit message.
62///
63/// This is used ONLY when all other methods fail:
64/// - All 8 prompt variants exhausted
65/// - All agents in fallback chain exhausted
66/// - All truncation stages failed
67/// - Emergency no-diff prompt failed
68/// - Deterministic fallback from diff failed
69///
70/// This ensures the commit process NEVER fails completely.
71const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
72
73/// Get the maximum safe prompt size for a specific agent.
74///
75/// Different agents have different token limits. This function returns a
76/// conservative max size for the given agent to prevent "prompt too long" errors.
77///
78/// # Arguments
79///
80/// * `commit_agent` - The commit agent command string
81///
82/// # Returns
83///
84/// Maximum safe prompt size in bytes
85fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
86    let agent_lower = commit_agent.to_lowercase();
87
88    // GLM and similar agents have smaller effective limits
89    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 // 100KB for GLM-like agents
96    } else if agent_lower.contains("claude")
97        || agent_lower.contains("ccs")
98        || agent_lower.contains("anthropic")
99    {
100        300_000 // 300KB for Claude-based agents
101    } else {
102        MAX_SAFE_PROMPT_SIZE // Default 200KB
103    }
104}
105
106/// Retry strategy for commit message generation.
107///
108/// Tracks which stage of re-prompting we're in, allowing for progressive
109/// degradation from detailed prompts to minimal ones before falling back
110/// to the next agent in the chain.
111#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112enum CommitRetryStrategy {
113    /// First attempt with normal prompt
114    Initial,
115    /// Re-prompt with strict JSON requirement
116    StrictJson,
117    /// Even stricter prompt with negative examples
118    StrictJsonV2,
119    /// Ultra-minimal prompt, no context
120    UltraMinimal,
121    /// Ultra-minimal V2 - even shorter
122    UltraMinimalV2,
123    /// File list only - no diff content
124    FileListOnly,
125    /// File list summary only - just file counts and categories
126    FileListSummaryOnly,
127    /// Emergency prompt - maximum strictness
128    Emergency,
129    /// Emergency no-diff - absolute last resort
130    EmergencyNoDiff,
131}
132
133impl CommitRetryStrategy {
134    /// Get the description of this retry stage for logging
135    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    /// Get the next retry strategy, or None if this is the last stage
150    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    /// Get the 1-based stage number for this strategy
165    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    /// Get the total number of retry stages
180    const fn total_stages() -> usize {
181        9 // Initial + 8 re-prompt variants
182    }
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
191/// Result of commit message generation.
192pub struct CommitMessageResult {
193    /// The generated commit message (may be empty on failure)
194    pub message: String,
195    /// Whether the generation was successful
196    pub success: bool,
197    /// Path to the agent log file for debugging (currently unused but kept for API compatibility)
198    pub _log_path: String,
199}
200
201/// Truncate diff if it's too large for agents with small context windows.
202///
203/// This is a defensive measure when agents report "prompt too long" errors.
204/// Returns a truncated diff with a summary of omitted content.
205///
206/// # Semantic Awareness
207///
208/// The improved truncation:
209/// 1. Preserves file structure - truncates at file boundaries (after `diff --git` blocks)
210/// 2. Prioritizes important files - keeps files from `src/` over `tests/`, `.md` files, etc.
211/// 3. Preserves last N files - shows what changed at the end
212/// 4. Adds a summary header - includes "First M files shown, N files truncated"
213fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
214    if diff.len() <= max_size {
215        return diff.to_string();
216    }
217
218    // Parse the diff into individual file blocks
219    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            // Save previous file if any
226            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            // Extract and prioritize the file path
233            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    // Don't forget the last file
243    if in_file && !current_file.lines.is_empty() {
244        files.push(current_file);
245    }
246
247    let total_files = files.len();
248
249    // Sort files by priority (highest first) to keep important files
250    files.sort_by_key(|f| std::cmp::Reverse(f.priority));
251
252    // Greedily select files that fit within max_size
253    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(); // +1 for newline
258
259        if current_size + file_size <= max_size {
260            current_size += file_size;
261            selected_files.push(file);
262        } else if current_size > 0 {
263            // We have at least one file and this one would exceed the limit
264            // Stop adding more files
265            break;
266        } else {
267            // Even the first (highest priority) file is too large
268            // Take at least the first part of it
269            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    // Build the truncated diff
283    let mut result = String::new();
284
285    // Add summary header at the top
286    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/// Represents a single file's diff chunk.
305#[derive(Debug, Default, Clone)]
306struct DiffFile {
307    /// File path (extracted from diff header)
308    path: String,
309    /// Priority for selection (higher = more important)
310    priority: i32,
311    /// Lines in this file's diff
312    lines: Vec<String>,
313}
314
315/// Assign a priority score to a file path for truncation selection.
316///
317/// Higher priority files are kept first when truncating:
318/// - src/*.rs: +100 (source code is most important)
319/// - src/*: +80 (other src files)
320/// - tests/*: +40 (tests are important but secondary)
321/// - Cargo.toml, package.json, etc.: +60 (config files)
322/// - docs/*, *.md: +20 (docs are least important)
323/// - Other: +50 (default)
324fn prioritize_file_path(path: &str) -> i32 {
325    use std::path::Path;
326    let path_lower = path.to_lowercase();
327
328    // Helper function for case-insensitive extension check
329    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    // Helper function for case-insensitive file extension check on path_lower
337    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    // Source code files (highest priority)
345    if path_lower.contains("src/") && has_ext_lower("rs") {
346        100
347    } else if path_lower.contains("src/") {
348        80
349    }
350    // Test files
351    else if path_lower.contains("test") {
352        40
353    }
354    // Config files - use case-insensitive extension check
355    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    // Documentation files (lowest priority)
364    else if path_lower.contains("doc") || has_ext("md") {
365        20
366    }
367    // Default priority
368    else {
369        50
370    }
371}
372
373/// Truncate a slice of lines to fit within a maximum size.
374///
375/// This is a fallback for when even a single file is too large.
376/// Returns as many complete lines as will fit.
377fn 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; // +1 for newline
383        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    // Add truncation marker to the last line
392    if let Some(last) = result.last_mut() {
393        last.push_str(" [truncated...]");
394    }
395
396    result
397}
398
399/// Check and pre-truncate diff if it exceeds agent's token limits.
400///
401/// Returns the (possibly truncated) diff and whether truncation occurred.
402fn 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
425/// Generate the appropriate prompt for the current retry strategy.
426fn 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
462/// Log the current attempt with prompt size information.
463fn 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
489/// Handle the extraction result from a commit attempt.
490///
491/// Returns `Some(result)` if we should return early (success or hard error),
492/// or `None` if we should continue to the next strategy.
493fn 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                // Only abort for truly unrecoverable errors (DiskFull, Permanent)
510                // All other errors should try simpler prompts before giving up
511                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                // For recoverable errors, try simpler prompts
525                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 // Continue to next strategy
534            } 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 // Continue to next strategy
542            } 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 // Continue to next strategy
563        }
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 // Continue to next strategy
570        }
571    }
572}
573
574/// Build the list of agents to try for commit generation.
575///
576/// This helper function constructs the ordered list of agents to try,
577/// starting with the primary agent and followed by configured fallbacks.
578fn 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
588/// Context for a commit attempt, bundling related state to avoid too many arguments.
589struct CommitAttemptContext<'a> {
590    /// The diff being processed
591    working_diff: &'a str,
592    /// Log directory path
593    log_dir: &'a str,
594    /// Whether the diff was pre-truncated
595    diff_was_truncated: bool,
596    /// Template context for user template overrides
597    template_context: &'a crate::prompts::TemplateContext,
598}
599
600/// Run a single commit attempt with the given strategy and agent.
601///
602/// This function runs a single agent (not using fallback) to allow for
603/// per-agent prompt variant cycling. Returns Some(result) if we should
604/// return early (success or hard error), or None if we should continue
605/// to the next strategy.
606fn 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    // Create attempt log
619    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    // Get the agent config
626    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    // Build the command for this agent
638    let cmd_str = agent_config.build_cmd(true, true, false);
639    let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
640
641    // Run the agent directly (without fallback)
642    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    // Write the attempt log
689    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
698/// Try progressive truncation recovery when `TokenExhausted` is detected.
699fn 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    // All truncation stages failed - try emergency no-diff
779    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
790/// Try further truncation recovery when already pre-truncated and still got `TokenExhausted`.
791fn 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
864/// Return the hardcoded fallback commit message as last resort.
865fn 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
890/// Try emergency no-diff recovery when truncation fails.
891fn 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; // Use original diff for no-diff prompt
904    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    // Emergency no-diff failed - generate fallback
935    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
946/// Generate a commit message using the standard agent pipeline with fallback.
947///
948/// This function uses the same `run_with_fallback()` pipeline as other phases,
949/// which provides:
950/// - Proper stdout/stderr logging
951/// - Configurable fallback chains
952/// - Retry logic with exponential backoff
953/// - Agent error classification
954///
955/// Multi-stage retry logic:
956/// 1. Try initial prompt
957/// 2. On fallback/empty result, try strict JSON prompt
958/// 3. On failure, try V2 strict prompt (with negative examples)
959/// 4. On failure, try ultra-minimal prompt
960/// 5. On failure, try emergency prompt
961/// 6. Only use hardcoded fallback after all prompt variants exhausted
962///
963/// # Agent Cycling Behavior
964///
965/// This function implements proper strategy-first cycling by trying each strategy
966/// with all agents before moving to the next strategy:
967/// - Strategy 1 (initial): Agent 1 → Agent 2 → Agent 3
968/// - Strategy 2 (strict JSON): Agent 1 → Agent 2 → Agent 3
969/// - Strategy 3 (strict JSON V2): Agent 1 → Agent 2 → Agent 3
970/// - etc.
971///
972/// This approach is more efficient because if a particular strategy works well
973/// with any agent, we succeed quickly rather than exhausting all strategies
974/// on the first agent before trying others.
975///
976/// # Arguments
977///
978/// * `diff` - The git diff to generate a commit message for
979/// * `registry` - The agent registry for resolving agents and fallbacks
980/// * `runtime` - The pipeline runtime for execution services
981/// * `commit_agent` - The primary agent to use for commit generation
982///
983/// # Returns
984///
985/// Returns `Ok(CommitMessageResult)` with the generated message and metadata.
986pub 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    // Create a logging session for this commit generation run
1000    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    // Try each agent with all prompt variants
1018    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    // Handle fallback cases
1032    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
1050/// Create a commit log session, with fallback.
1051fn 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
1071/// Try all agents with their strategy variants.
1072///
1073/// This function implements strategy-first cycling:
1074/// - Outer loop: Iterate through strategies
1075/// - Inner loop: Try all agents with the current strategy
1076/// - Only advance to next strategy if ALL agents failed with current strategy
1077///
1078/// This ensures each strategy gets the best chance to succeed with all
1079/// available agents before we try degraded fallback prompts.
1080fn 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
1132/// Log completion info and write session summary on success.
1133fn 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
1154/// Context for commit fallback handling.
1155struct 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
1164/// Handle fallback cases after all agents exhausted.
1165fn 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    // Use fallback from last extraction if available
1174    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    // Token exhausted recovery
1195    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    // Hardcoded fallback as last resort
1241    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
1252/// Handle agent error fallback.
1253fn 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
1286/// Handle extraction fallback (non-error).
1287fn 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
1313/// Create a commit with an automatically generated message using the standard pipeline.
1314///
1315/// This is a replacement for `commit_with_auto_message_fallback_result` in `git_helpers`
1316/// that uses the standard agent pipeline with proper logging and fallback support.
1317///
1318/// # Arguments
1319///
1320/// * `diff` - The git diff to generate a commit message from
1321/// * `commit_agent` - The primary agent to use for commit generation
1322/// * `git_user_name` - Optional git user name
1323/// * `git_user_email` - Optional git user email
1324/// * `ctx` - The phase context containing registry, logger, colors, config, and timer
1325///
1326/// # Returns
1327///
1328/// Returns `CommitResultFallback` indicating success, no changes, or failure.
1329pub 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    // Stage all changes first
1337    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    // Set up the runtime
1349    let mut runtime = PipelineRuntime {
1350        timer: ctx.timer,
1351        logger: ctx.logger,
1352        colors: ctx.colors,
1353        config: ctx.config,
1354    };
1355
1356    // Generate commit message using the standard pipeline
1357    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    // Check if generation succeeded
1371    if !result.success || result.message.trim().is_empty() {
1372        // This should never happen after our fixes, but add defensive fallback
1373        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        // Create the commit with the generated message
1383        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
1391/// Extract a commit message from the agent log files.
1392///
1393/// This function reads the most recent agent log file and extracts
1394/// the commit message using the standard LLM output extraction logic.
1395///
1396/// # Arguments
1397///
1398/// * `log_dir` - Directory containing the agent log files
1399/// * `diff` - The original diff (for context/error messages)
1400/// * `agent_cmd` - The agent command (for format hint detection)
1401/// * `logger` - Logger for diagnostic output
1402///
1403/// # Returns
1404///
1405/// * `Ok(CommitExtractionResult)` - A result indicating how the message was obtained:
1406///   - `Extracted` - Successfully extracted from structured output
1407///   - `Salvaged` - Recovered from mixed output via salvage mechanism
1408///   - `Fallback` - Using deterministic fallback (caller should consider re-prompt)
1409/// * `Err(e)` - An error occurred during extraction (e.g., file I/O error)
1410fn 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    // Find the most recent log file
1417    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    // Read the log file
1430    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    // PRE-PROCESS: Apply aggressive escape sequence unescaping BEFORE any other processing
1440    content = preprocess_raw_content(&content);
1441
1442    // FIRST: Detect agent errors in the output stream BEFORE attempting extraction
1443    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    // SECOND: Try XML extraction (new primary method) - with tracing
1452    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        // Validate
1459        let report = validate_commit_message_with_report(&message);
1460        if report.all_passed() {
1461            return Ok(Some(CommitExtractionResult::Extracted(message)));
1462        }
1463        // Fall through to try other methods if validation failed
1464        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    // THIRD: Try structured JSON extraction - with tracing
1476    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        // Validate
1483        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    // Pattern-based extraction with recovery layers
1499    Ok(try_pattern_extraction_with_recovery(
1500        &content, diff, agent_cmd, logger,
1501    ))
1502}
1503
1504/// Validate and record extraction result.
1505///
1506/// Returns `Some(CommitExtractionResult::Extracted)` if valid, `None` if validation failed.
1507fn 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    // Record validation checks
1517    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
1550/// Import types needed for parsing trace helpers.
1551use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1552
1553/// Write parsing trace log to file with error handling.
1554fn 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
1564/// Try XML extraction and record in parsing trace.
1565/// Returns `Some(result)` if extraction succeeded, `None` otherwise.
1566fn 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
1601/// Try JSON extraction and record in parsing trace.
1602/// Returns `Some(result)` if extraction succeeded, `None` otherwise.
1603fn 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
1638/// Extract a commit message from agent logs with full tracing for diagnostics.
1639///
1640/// Similar to `extract_commit_message_from_logs` but records all extraction
1641/// attempts in the provided `CommitAttemptLog` for debugging.
1642///
1643/// This function also creates and writes a `ParsingTraceLog` that captures
1644/// detailed information about each extraction step, including the exact
1645/// content being processed and validation results.
1646fn 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    // Create parsing trace log
1654    let mut parsing_trace = ParsingTraceLog::new(
1655        attempt_log.attempt_number,
1656        &attempt_log.agent,
1657        &attempt_log.strategy,
1658    );
1659
1660    // Read and preprocess log content
1661    let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1662        return Ok(None);
1663    };
1664
1665    // Set raw output in parsing trace
1666    parsing_trace.set_raw_output(&content);
1667
1668    // Detect agent errors in the output stream
1669    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        // Add to parsing trace
1680        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    // Try XML extraction
1695    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    // Try JSON extraction
1707    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    // Pattern-based extraction with recovery layers
1719    let pattern_result =
1720        try_pattern_extraction_with_recovery_traced(&content, diff, agent_cmd, logger, attempt_log);
1721
1722    // Add pattern extraction step to parsing trace
1723    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
1747/// Read and preprocess log content for extraction.
1748fn 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    // Apply preprocessing
1783    Ok(Some(preprocess_raw_content(&content)))
1784}
1785
1786/// Try pattern-based extraction with tracing for attempt logging.
1787fn 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    // Log extraction metadata for debugging
1798    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    // Validate the commit message
1810    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
1832/// Attempt recovery layers with tracing for attempt logging.
1833fn 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    // Recovery Layer 1: Attempt to salvage valid commit message from raw content
1843    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    // Recovery Layer 2: Generate deterministic fallback from diff metadata
1859    logger.info("Generating fallback commit message from diff...");
1860    let fallback = generate_fallback_commit_message(diff);
1861
1862    // Defensive validation (should always pass, but be safe)
1863    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
1883/// Detect output format hint from agent command string.
1884fn 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
1904/// Try pattern-based extraction with recovery layers.
1905fn 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    // Log extraction metadata for debugging
1915    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    // Validate the commit message
1927    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
1936/// Attempt recovery layers when extraction fails validation.
1937fn 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    // Recovery Layer 1: Attempt to salvage valid commit message from raw content
1946    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    // Recovery Layer 2: Generate deterministic fallback from diff metadata
1954    logger.info("Generating fallback commit message from diff...");
1955    let fallback = generate_fallback_commit_message(diff);
1956
1957    // Defensive validation (should always pass, but be safe)
1958    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
1970/// Find the most recently modified log file in a directory or matching a prefix pattern.
1971///
1972/// Supports two modes:
1973/// 1. **Directory mode**: If `log_path` is a directory, find the most recent `.log` file in it
1974/// 2. **Prefix mode**: If `log_path` is not a directory, treat it as a prefix pattern and
1975///    search for files matching `{prefix}*.log` in the parent directory
1976///
1977/// # Arguments
1978///
1979/// * `log_path` - Either a directory path or a prefix pattern for log files
1980///
1981/// # Returns
1982///
1983/// * `Ok(Some(path))` - Path to the most recent log file
1984/// * `Ok(None)` - No log files found
1985/// * `Err(e)` - Error reading directory
1986fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1987    let path = std::path::PathBuf::from(log_path);
1988
1989    // Mode 1: If path is a directory, search for .log files with empty prefix (matches all)
1990    if path.is_dir() {
1991        return find_most_recent_log_with_prefix(&path, "");
1992    }
1993
1994    // Mode 2: Prefix pattern mode - search parent directory for files starting with the prefix
1995    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
2009/// Find the most recently modified log file matching a prefix pattern.
2010///
2011/// Only matches `.log` files that start with `prefix`. If `prefix` is empty,
2012/// matches any `.log` file in the directory.
2013fn 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        // Only look at .log files that start with the prefix (or any .log file if prefix is empty)
2028        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        // Test with non-existent directory
2063        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        // Should be truncated
2074        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        // Should not be modified
2083        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        // Should not be modified when at exact limit
2092        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        // Should preserve complete file blocks
2107        assert!(truncated.contains("diff --git"));
2108        // Should contain truncation summary
2109        assert!(truncated.contains("Diff truncated"));
2110    }
2111
2112    #[test]
2113    fn test_prioritize_file_path() {
2114        // Source files get highest priority
2115        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        // Tests get lower priority than src
2119        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
2120
2121        // Config files get medium priority
2122        assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
2123
2124        // Docs get lowest priority
2125        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        // With a very small limit, should keep src/main.rs first
2138        let truncated = truncate_diff_if_large(diff, 80);
2139
2140        // Should include the high priority src file
2141        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        // Only fit first 3 lines (each 5 chars + newline = 6)
2154        let truncated = truncate_lines_to_fit(&lines, 18);
2155
2156        assert_eq!(truncated.len(), 3);
2157        // Last line should have truncation marker
2158        assert!(truncated[2].ends_with("[truncated...]"));
2159    }
2160
2161    #[test]
2162    fn test_hardcoded_fallback_commit() {
2163        // The hardcoded fallback should always be valid
2164        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}