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,
15};
16use super::context::PhaseContext;
17use crate::agents::{AgentRegistry, AgentRole};
18use crate::checkpoint::execution_history::{ExecutionStep, StepOutcome};
19use crate::files::llm_output_extraction::{
20    preprocess_raw_content, try_extract_xml_commit_with_trace, CommitExtractionResult,
21};
22use crate::git_helpers::{git_add_all, git_commit, CommitResultFallback};
23use crate::logger::Logger;
24use crate::pipeline::PipelineRuntime;
25use crate::prompts::{
26    get_stored_or_generate_prompt, prompt_generate_commit_message_with_diff_with_context,
27    prompt_simplified_commit_with_context, prompt_xsd_retry_with_context,
28};
29use std::collections::HashMap;
30use std::fmt;
31use std::fs::{self, File};
32use std::io::Read;
33
34/// Preview a commit message for display (first line, truncated if needed).
35fn preview_commit_message(msg: &str) -> String {
36    let first_line = msg.lines().next().unwrap_or(msg);
37    if first_line.len() > 60 {
38        format!("{}...", &first_line[..60])
39    } else {
40        first_line.to_string()
41    }
42}
43
44/// Maximum safe prompt size in bytes before pre-truncation.
45///
46/// This is a conservative limit to prevent agents from failing with "prompt too long"
47/// errors. Different agents have different token limits:
48/// - GLM: ~100KB effective limit
49/// - Claude CCS: ~300KB effective limit
50/// - Others: vary by model
51///
52/// We use 200KB as a safe middle ground that works for most agents while still
53/// allowing substantial diffs to be processed without truncation.
54const MAX_SAFE_PROMPT_SIZE: usize = 200_000;
55
56/// Absolute last resort fallback commit message.
57///
58/// This is used ONLY when all other methods fail:
59/// - All 8 prompt variants exhausted
60/// - All agents in fallback chain exhausted
61/// - All truncation stages failed
62/// - Emergency no-diff prompt failed
63/// - Deterministic fallback from diff failed
64///
65/// This ensures the commit process NEVER fails completely.
66pub(crate) const HARDCODED_FALLBACK_COMMIT: &str = "chore: automated commit";
67
68/// Get the maximum safe prompt size for a specific agent.
69///
70/// Different agents have different token limits. This function returns a
71/// conservative max size for the given agent to prevent "prompt too long" errors.
72///
73/// # Arguments
74///
75/// * `commit_agent` - The commit agent command string
76///
77/// # Returns
78///
79/// Maximum safe prompt size in bytes
80fn max_prompt_size_for_agent(commit_agent: &str) -> usize {
81    let agent_lower = commit_agent.to_lowercase();
82
83    // GLM and similar agents have smaller effective limits
84    if agent_lower.contains("glm")
85        || agent_lower.contains("zhipuai")
86        || agent_lower.contains("zai")
87        || agent_lower.contains("qwen")
88        || agent_lower.contains("deepseek")
89    {
90        100_000 // 100KB for GLM-like agents
91    } else if agent_lower.contains("claude")
92        || agent_lower.contains("ccs")
93        || agent_lower.contains("anthropic")
94    {
95        300_000 // 300KB for Claude-based agents
96    } else {
97        MAX_SAFE_PROMPT_SIZE // Default 200KB
98    }
99}
100
101/// Retry strategy for commit message generation.
102///
103/// Tracks which stage of re-prompting we're in, allowing for progressive
104/// degradation from detailed prompts to minimal ones before falling back
105/// to the next agent in the chain.
106///
107/// With XSD validation, we now have two strategies. Each strategy supports
108/// up to 5 in-session retries with validation feedback.
109///
110/// The XSD retry mechanism is used internally for in-session retries when
111/// validation fails, not as a separate stage.
112#[derive(Debug, Clone, Copy, PartialEq, Eq)]
113enum CommitRetryStrategy {
114    /// First attempt with normal XML prompt
115    Normal,
116    /// Simplified XML prompt - more direct instructions
117    Simplified,
118}
119
120impl CommitRetryStrategy {
121    /// Get the description of this retry stage for logging
122    const fn description(self) -> &'static str {
123        match self {
124            Self::Normal => "normal XML prompt",
125            Self::Simplified => "simplified XML prompt",
126        }
127    }
128
129    /// Get the next retry strategy, or None if this is the last stage
130    const fn next(self) -> Option<Self> {
131        match self {
132            Self::Normal => Some(Self::Simplified),
133            Self::Simplified => None,
134        }
135    }
136
137    /// Get the 1-based stage number for this strategy
138    const fn stage_number(self) -> usize {
139        match self {
140            Self::Normal => 1,
141            Self::Simplified => 2,
142        }
143    }
144
145    /// Get the total number of retry stages
146    const fn total_stages() -> usize {
147        2 // Normal + Simplified
148    }
149
150    /// Get the maximum number of in-session retries for this strategy
151    const fn max_session_retries(self) -> usize {
152        match self {
153            Self::Normal => 10,     // Allow 10 retries with XSD validation feedback
154            Self::Simplified => 10, // Allow 10 retries for simplified prompt
155        }
156    }
157}
158
159impl fmt::Display for CommitRetryStrategy {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(f, "{}", self.description())
162    }
163}
164
165/// Result of commit message generation.
166pub struct CommitMessageResult {
167    /// The generated commit message (may be empty on failure)
168    pub message: String,
169    /// Whether the generation was successful
170    pub success: bool,
171    /// Path to the agent log file for debugging (currently unused but kept for API compatibility)
172    pub _log_path: String,
173    /// Prompts that were generated during this commit generation (key -> prompt)
174    /// This is used for capturing prompts in checkpoints for deterministic resume
175    pub generated_prompts: std::collections::HashMap<String, String>,
176}
177
178/// Truncate diff if it's too large for agents with small context windows.
179///
180/// This is a defensive measure when agents report "prompt too long" errors.
181/// Returns a truncated diff with a summary of omitted content.
182///
183/// # Semantic Awareness
184///
185/// The improved truncation:
186/// 1. Preserves file structure - truncates at file boundaries (after `diff --git` blocks)
187/// 2. Prioritizes important files - keeps files from `src/` over `tests/`, `.md` files, etc.
188/// 3. Preserves last N files - shows what changed at the end
189/// 4. Adds a summary header - includes "First M files shown, N files truncated"
190fn truncate_diff_if_large(diff: &str, max_size: usize) -> String {
191    if diff.len() <= max_size {
192        return diff.to_string();
193    }
194
195    // Parse the diff into individual file blocks
196    let mut files: Vec<DiffFile> = Vec::new();
197    let mut current_file = DiffFile::default();
198    let mut in_file = false;
199
200    for line in diff.lines() {
201        if line.starts_with("diff --git ") {
202            // Save previous file if any
203            if in_file && !current_file.lines.is_empty() {
204                files.push(std::mem::take(&mut current_file));
205            }
206            in_file = true;
207            current_file.lines.push(line.to_string());
208
209            // Extract and prioritize the file path
210            if let Some(path) = line.split(" b/").nth(1) {
211                current_file.path = path.to_string();
212                current_file.priority = prioritize_file_path(path);
213            }
214        } else if in_file {
215            current_file.lines.push(line.to_string());
216        }
217    }
218
219    // Don't forget the last file
220    if in_file && !current_file.lines.is_empty() {
221        files.push(current_file);
222    }
223
224    let total_files = files.len();
225
226    // Sort files by priority (highest first) to keep important files
227    files.sort_by_key(|f| std::cmp::Reverse(f.priority));
228
229    // Greedily select files that fit within max_size
230    let mut selected_files = Vec::new();
231    let mut current_size = 0;
232
233    for file in files {
234        let file_size: usize = file.lines.iter().map(|l| l.len() + 1).sum(); // +1 for newline
235
236        if current_size + file_size <= max_size {
237            current_size += file_size;
238            selected_files.push(file);
239        } else if current_size > 0 {
240            // We have at least one file and this one would exceed the limit
241            // Stop adding more files
242            break;
243        } else {
244            // Even the first (highest priority) file is too large
245            // Take at least the first part of it
246            let truncated_lines = truncate_lines_to_fit(&file.lines, max_size);
247            selected_files.push(DiffFile {
248                path: file.path,
249                priority: file.priority,
250                lines: truncated_lines,
251            });
252            break;
253        }
254    }
255
256    let selected_count = selected_files.len();
257    let omitted_count = total_files.saturating_sub(selected_count);
258
259    // Build the truncated diff
260    let mut result = String::new();
261
262    // Add summary header at the top
263    if omitted_count > 0 {
264        use std::fmt::Write;
265        let _ = write!(
266            result,
267            "[Diff truncated: Showing first {selected_count} of {total_files} files. {omitted_count} files omitted due to size constraints.]\n\n"
268        );
269    }
270
271    for file in selected_files {
272        for line in &file.lines {
273            result.push_str(line);
274            result.push('\n');
275        }
276    }
277
278    result
279}
280
281/// Represents a single file's diff chunk.
282#[derive(Debug, Default, Clone)]
283struct DiffFile {
284    /// File path (extracted from diff header)
285    path: String,
286    /// Priority for selection (higher = more important)
287    priority: i32,
288    /// Lines in this file's diff
289    lines: Vec<String>,
290}
291
292/// Assign a priority score to a file path for truncation selection.
293///
294/// Higher priority files are kept first when truncating:
295/// - src/*.rs: +100 (source code is most important)
296/// - src/*: +80 (other src files)
297/// - tests/*: +40 (tests are important but secondary)
298/// - Cargo.toml, package.json, etc.: +60 (config files)
299/// - docs/*, *.md: +20 (docs are least important)
300/// - Other: +50 (default)
301fn prioritize_file_path(path: &str) -> i32 {
302    use std::path::Path;
303    let path_lower = path.to_lowercase();
304
305    // Helper function for case-insensitive extension check
306    let has_ext = |ext: &str| -> bool {
307        Path::new(path)
308            .extension()
309            .and_then(std::ffi::OsStr::to_str)
310            .is_some_and(|e| e.eq_ignore_ascii_case(ext))
311    };
312
313    // Helper function for case-insensitive file extension check on path_lower
314    let has_ext_lower = |ext: &str| -> bool {
315        Path::new(&path_lower)
316            .extension()
317            .and_then(std::ffi::OsStr::to_str)
318            .is_some_and(|e| e.eq_ignore_ascii_case(ext))
319    };
320
321    // Source code files (highest priority)
322    if path_lower.contains("src/") && has_ext_lower("rs") {
323        100
324    } else if path_lower.contains("src/") {
325        80
326    }
327    // Test files
328    else if path_lower.contains("test") {
329        40
330    }
331    // Config files - use case-insensitive extension check
332    else if has_ext("toml")
333        || has_ext("json")
334        || path_lower.ends_with("cargo.toml")
335        || path_lower.ends_with("package.json")
336        || path_lower.ends_with("tsconfig.json")
337    {
338        60
339    }
340    // Documentation files (lowest priority)
341    else if path_lower.contains("doc") || has_ext("md") {
342        20
343    }
344    // Default priority
345    else {
346        50
347    }
348}
349
350/// Truncate a slice of lines to fit within a maximum size.
351///
352/// This is a fallback for when even a single file is too large.
353/// Returns as many complete lines as will fit.
354fn truncate_lines_to_fit(lines: &[String], max_size: usize) -> Vec<String> {
355    let mut result = Vec::new();
356    let mut current_size = 0;
357
358    for line in lines {
359        let line_size = line.len() + 1; // +1 for newline
360        if current_size + line_size <= max_size {
361            current_size += line_size;
362            result.push(line.clone());
363        } else {
364            break;
365        }
366    }
367
368    // Add truncation marker to the last line
369    if let Some(last) = result.last_mut() {
370        last.push_str(" [truncated...]");
371    }
372
373    result
374}
375
376/// Check and pre-truncate diff if it exceeds agent's token limits.
377///
378/// Returns the (possibly truncated) diff and whether truncation occurred.
379fn check_and_pre_truncate_diff(
380    diff: &str,
381    commit_agent: &str,
382    runtime: &PipelineRuntime,
383) -> (String, bool) {
384    let max_size = max_prompt_size_for_agent(commit_agent);
385    if diff.len() > max_size {
386        runtime.logger.warn(&format!(
387            "Diff size ({} KB) exceeds agent limit ({} KB). Pre-truncating to avoid token errors.",
388            diff.len() / 1024,
389            max_size / 1024
390        ));
391        (truncate_diff_if_large(diff, max_size), true)
392    } else {
393        runtime.logger.info(&format!(
394            "Diff size ({} KB) is within safe limit ({} KB).",
395            diff.len() / 1024,
396            max_size / 1024
397        ));
398        (diff.to_string(), false)
399    }
400}
401
402/// Generate the appropriate prompt for the current retry strategy.
403///
404/// For XSD retry, the xsd_error parameter is used to provide feedback to the agent.
405/// Note: XSD retry is handled internally within the session, not as a separate stage.
406///
407/// For hardened resume, this function uses stored prompts from checkpoint when available
408/// to ensure deterministic behavior on resume.
409fn generate_prompt_for_strategy(
410    strategy: CommitRetryStrategy,
411    working_diff: &str,
412    template_context: &crate::prompts::TemplateContext,
413    xsd_error: Option<&str>,
414    prompt_history: &HashMap<String, String>,
415    prompt_key: &str,
416) -> (String, bool) {
417    // Use stored_or_generate pattern for hardened resume
418    // The key identifies which prompt variant this is (strategy + retry state)
419    let full_prompt_key = if xsd_error.is_some() {
420        format!("{}_xsd_retry", prompt_key)
421    } else {
422        prompt_key.to_string()
423    };
424
425    let (prompt, was_replayed) =
426        get_stored_or_generate_prompt(&full_prompt_key, prompt_history, || match strategy {
427            CommitRetryStrategy::Normal => {
428                if let Some(error_msg) = xsd_error {
429                    // In-session XSD retry with error feedback
430                    prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
431                } else {
432                    // First attempt with normal XML prompt
433                    prompt_generate_commit_message_with_diff_with_context(
434                        template_context,
435                        working_diff,
436                    )
437                }
438            }
439            CommitRetryStrategy::Simplified => {
440                if let Some(error_msg) = xsd_error {
441                    // In-session XSD retry with error feedback
442                    prompt_xsd_retry_with_context(template_context, working_diff, error_msg)
443                } else {
444                    // Simplified XML prompt
445                    prompt_simplified_commit_with_context(template_context, working_diff)
446                }
447            }
448        });
449
450    (prompt, was_replayed)
451}
452
453/// Log the current attempt with prompt size information.
454fn log_commit_attempt(
455    strategy: CommitRetryStrategy,
456    prompt_size_kb: usize,
457    commit_agent: &str,
458    runtime: &PipelineRuntime,
459) {
460    if strategy == CommitRetryStrategy::Normal {
461        runtime.logger.info(&format!(
462            "Attempt 1/{}: Using {} (prompt size: {} KB, agent: {})",
463            CommitRetryStrategy::total_stages(),
464            strategy,
465            prompt_size_kb,
466            commit_agent
467        ));
468    } else {
469        runtime.logger.warn(&format!(
470            "Attempt {}/{}: Re-prompting with {} (prompt size: {} KB, agent: {})...",
471            strategy as usize + 1,
472            CommitRetryStrategy::total_stages(),
473            strategy,
474            prompt_size_kb,
475            commit_agent
476        ));
477    }
478}
479
480/// Handle the extraction result from a commit attempt.
481///
482/// Returns `Some(result)` if we should return early (success),
483/// or `None` if we should continue to the next strategy.
484///
485/// With XSD validation handling everything, we only check:
486/// - Success: Valid commit message extracted
487/// - Failure: No valid message (try next strategy)
488fn handle_commit_extraction_result(
489    extraction_result: anyhow::Result<Option<CommitExtractionResult>>,
490    strategy: CommitRetryStrategy,
491    log_dir: &str,
492    runtime: &PipelineRuntime,
493    last_extraction: &mut Option<CommitExtractionResult>,
494    attempt_log: &mut CommitAttemptLog,
495) -> Option<anyhow::Result<CommitMessageResult>> {
496    let log_file = format!("{log_dir}/final.log");
497
498    match extraction_result {
499        Ok(Some(extraction)) => {
500            // XSD validation already passed - we have a valid commit message
501            runtime.logger.info(&format!(
502                "Successfully extracted commit message with {strategy}"
503            ));
504            let message = extraction.clone().into_message();
505            attempt_log.set_outcome(AttemptOutcome::Success(message.clone()));
506            *last_extraction = Some(extraction);
507            // Note: generated_prompts is collected in the context and returned later
508            Some(Ok(CommitMessageResult {
509                message,
510                success: true,
511                _log_path: log_file,
512                generated_prompts: std::collections::HashMap::new(),
513            }))
514        }
515        Ok(None) => {
516            runtime.logger.warn(&format!(
517                "No valid commit message extracted with {strategy}, will try next strategy"
518            ));
519            attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(
520                "No valid commit message extracted".to_string(),
521            ));
522            None // Continue to next strategy
523        }
524        Err(e) => {
525            runtime.logger.error(&format!(
526                "Failed to extract commit message with {strategy}: {e}"
527            ));
528            attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(e.to_string()));
529            None // Continue to next strategy
530        }
531    }
532}
533
534/// Build the list of agents to try for commit generation.
535///
536/// This helper function constructs the ordered list of agents to try,
537/// starting with the primary agent and followed by configured fallbacks.
538fn build_agents_to_try<'a>(fallbacks: &'a [&'a str], primary_agent: &'a str) -> Vec<&'a str> {
539    let mut agents_to_try: Vec<&'a str> = vec![primary_agent];
540    for fb in fallbacks {
541        if *fb != primary_agent && !agents_to_try.contains(fb) {
542            agents_to_try.push(fb);
543        }
544    }
545    agents_to_try
546}
547
548/// Context for a commit attempt, bundling related state to avoid too many arguments.
549struct CommitAttemptContext<'a> {
550    /// The diff being processed
551    working_diff: &'a str,
552    /// Log directory path
553    log_dir: &'a str,
554    /// Whether the diff was pre-truncated
555    diff_was_truncated: bool,
556    /// Template context for user template overrides
557    template_context: &'a crate::prompts::TemplateContext,
558    /// Prompt history for checkpoint/resume determinism
559    prompt_history: &'a HashMap<String, String>,
560    /// Unique key for this commit generation attempt
561    prompt_key: String,
562    /// Output map to capture prompts that were newly generated (not replayed)
563    /// This is used for checkpoint/resume determinism
564    generated_prompts: &'a mut std::collections::HashMap<String, String>,
565}
566
567/// Run a single commit attempt with the given strategy and agent.
568///
569/// This function runs a single agent (not using fallback) to allow for
570/// per-agent prompt variant cycling with in-session XSD validation retry.
571/// Returns Some(result) if we should return early (success or hard error),
572/// or None if we should continue to the next strategy.
573fn run_commit_attempt_with_agent(
574    strategy: CommitRetryStrategy,
575    ctx: &mut CommitAttemptContext<'_>,
576    runtime: &mut PipelineRuntime,
577    registry: &AgentRegistry,
578    agent: &str,
579    last_extraction: &mut Option<CommitExtractionResult>,
580    session: &mut CommitLogSession,
581) -> Option<anyhow::Result<CommitMessageResult>> {
582    // Get the agent config
583    let Some(agent_config) = registry.resolve_config(agent) else {
584        runtime
585            .logger
586            .warn(&format!("Agent '{agent}' not found in registry, skipping"));
587        let mut attempt_log = session.new_attempt(agent, strategy.description());
588        attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
589            "Agent '{agent}' not found in registry"
590        )));
591        let _ = attempt_log.write_to_file(session.run_dir());
592        return None;
593    };
594
595    // Build the command for this agent
596    let cmd_str = agent_config.build_cmd(true, true, false);
597    let logfile = format!("{}/{}_latest.log", ctx.log_dir, agent.replace('/', "-"));
598
599    // In-session retry loop with XSD validation feedback
600    let max_retries = strategy.max_session_retries();
601    let mut xsd_error: Option<String> = None;
602
603    for retry_num in 0..max_retries {
604        // For initial attempt, xsd_error is None
605        // For retries, we use the XSD error to guide the agent
606        // Build prompt key for this attempt (strategy-specific)
607        let prompt_key = format!("{}_{}", ctx.prompt_key, strategy.stage_number());
608        let (prompt, was_replayed) = generate_prompt_for_strategy(
609            strategy,
610            ctx.working_diff,
611            ctx.template_context,
612            xsd_error.as_deref(),
613            ctx.prompt_history,
614            &prompt_key,
615        );
616        let prompt_size_kb = prompt.len() / 1024;
617
618        // Log if using stored prompt for determinism
619        if was_replayed && retry_num == 0 {
620            runtime.logger.info(&format!(
621                "Using stored prompt from checkpoint for determinism: {}",
622                prompt_key
623            ));
624        } else if !was_replayed {
625            // Capture the newly generated prompt for checkpoint/resume
626            ctx.generated_prompts
627                .insert(prompt_key.clone(), prompt.clone());
628        }
629
630        // Create attempt log
631        let mut attempt_log = session.new_attempt(agent, strategy.description());
632        attempt_log.set_prompt_size(prompt.len());
633        attempt_log.set_diff_info(ctx.working_diff.len(), ctx.diff_was_truncated);
634
635        // Log retry attempt if not first attempt
636        if retry_num > 0 {
637            runtime.logger.info(&format!(
638                "  In-session retry {}/{} for XSD validation",
639                retry_num,
640                max_retries - 1
641            ));
642            if let Some(ref error) = xsd_error {
643                runtime.logger.info(&format!("  XSD error: {}", error));
644            }
645        } else {
646            log_commit_attempt(strategy, prompt_size_kb, agent, runtime);
647        }
648
649        // Run the agent directly (without fallback)
650        let exit_code = match crate::pipeline::run_with_prompt(
651            &crate::pipeline::PromptCommand {
652                label: &format!("generate commit message ({})", strategy.description()),
653                display_name: agent,
654                cmd_str: &cmd_str,
655                prompt: &prompt,
656                logfile: &logfile,
657                parser_type: agent_config.json_parser,
658                env_vars: &agent_config.env_vars,
659            },
660            runtime,
661        ) {
662            Ok(result) => result.exit_code,
663            Err(e) => {
664                runtime.logger.error(&format!("Failed to run agent: {e}"));
665                attempt_log.set_outcome(AttemptOutcome::ExtractionFailed(format!(
666                    "Agent execution failed: {e}"
667                )));
668                let _ = attempt_log.write_to_file(session.run_dir());
669                return None;
670            }
671        };
672
673        if exit_code != 0 {
674            runtime
675                .logger
676                .warn("Commit agent failed, checking logs for partial output...");
677        }
678
679        let extraction_result = extract_commit_message_from_logs_with_trace(
680            ctx.log_dir,
681            ctx.working_diff,
682            agent,
683            runtime.logger,
684            &mut attempt_log,
685        );
686
687        // Check if we got a valid commit message or need to retry for XSD errors
688        match &extraction_result {
689            Ok(Some(_)) => {
690                // XSD validation passed - we have a valid commit message
691                let result = handle_commit_extraction_result(
692                    extraction_result,
693                    strategy,
694                    ctx.log_dir,
695                    runtime,
696                    last_extraction,
697                    &mut attempt_log,
698                );
699
700                if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
701                    runtime
702                        .logger
703                        .warn(&format!("Failed to write attempt log: {e}"));
704                }
705
706                return result;
707            }
708            _ => {
709                // Extraction failed - continue to check for XSD errors for retry
710            }
711        };
712
713        // Check extraction attempts for XSD validation errors
714        let xsd_error_msg = attempt_log
715            .extraction_attempts
716            .iter()
717            .find(|attempt| attempt.detail.contains("XSD validation failed"))
718            .map(|attempt| attempt.detail.clone());
719
720        if let Some(ref error_msg) = xsd_error_msg {
721            runtime
722                .logger
723                .warn(&format!("  XSD validation failed: {}", error_msg));
724
725            if retry_num < max_retries - 1 {
726                // Extract just the error message (after "XSD validation failed: ")
727                let error = error_msg
728                    .strip_prefix("XSD validation failed: ")
729                    .unwrap_or(error_msg);
730
731                // Store error for next retry attempt
732                xsd_error = Some(error.to_string());
733
734                // Write attempt log but don't return yet
735                attempt_log.set_outcome(AttemptOutcome::XsdValidationFailed(error.to_string()));
736                let _ = attempt_log.write_to_file(session.run_dir());
737
738                // Continue to next retry iteration
739                continue;
740            } else {
741                // No more retries - fall through to handle as extraction failure
742                runtime
743                    .logger
744                    .warn("  No more in-session retries remaining");
745            }
746        }
747
748        // Handle extraction result (failure cases)
749        let result = handle_commit_extraction_result(
750            extraction_result,
751            strategy,
752            ctx.log_dir,
753            runtime,
754            last_extraction,
755            &mut attempt_log,
756        );
757
758        // Write the attempt log
759        if let Err(e) = attempt_log.write_to_file(session.run_dir()) {
760            runtime
761                .logger
762                .warn(&format!("Failed to write attempt log: {e}"));
763        }
764
765        // If we got a result (success or hard error), return it
766        if result.is_some() {
767            return result;
768        }
769
770        // Otherwise, if this was a retry and we exhausted retries, break out
771        if retry_num >= max_retries - 1 {
772            break;
773        }
774
775        // For non-XSD errors, we don't retry in-session - move to next strategy
776        break;
777    }
778
779    None
780}
781
782/// Return the hardcoded fallback commit message as last resort.
783fn return_hardcoded_fallback(
784    log_file: &str,
785    runtime: &PipelineRuntime,
786    generated_prompts: std::collections::HashMap<String, String>,
787) -> CommitMessageResult {
788    runtime.logger.warn("");
789    runtime.logger.warn("All recovery methods failed:");
790    runtime.logger.warn("  - All 9 prompt variants exhausted");
791    runtime
792        .logger
793        .warn("  - All agents in fallback chain exhausted");
794    runtime.logger.warn("  - All truncation stages failed");
795    runtime.logger.warn("  - Emergency prompts failed");
796    runtime.logger.warn("");
797    runtime
798        .logger
799        .warn("Using hardcoded fallback commit message as last resort.");
800    runtime.logger.warn(&format!(
801        "Fallback message: \"{HARDCODED_FALLBACK_COMMIT}\""
802    ));
803    runtime.logger.warn("");
804
805    CommitMessageResult {
806        message: HARDCODED_FALLBACK_COMMIT.to_string(),
807        success: true,
808        _log_path: log_file.to_string(),
809        generated_prompts,
810    }
811}
812
813/// Generate a commit message using the standard agent pipeline with fallback.
814///
815/// This function uses the same `run_with_fallback()` pipeline as other phases,
816/// which provides:
817/// - Proper stdout/stderr logging
818/// - Configurable fallback chains
819/// - Retry logic with exponential backoff
820/// - Agent error classification
821///
822/// Multi-stage retry logic:
823/// 1. Try initial prompt
824/// 2. On fallback/empty result, try strict JSON prompt
825/// 3. On failure, try V2 strict prompt (with negative examples)
826/// 4. On failure, try ultra-minimal prompt
827/// 5. On failure, try emergency prompt
828/// 6. Only use hardcoded fallback after all prompt variants exhausted
829///
830/// # Agent Cycling Behavior
831///
832/// This function implements proper strategy-first cycling by trying each strategy
833/// with all agents before moving to the next strategy:
834/// - Strategy 1 (initial): Agent 1 → Agent 2 → Agent 3
835/// - Strategy 2 (strict JSON): Agent 1 → Agent 2 → Agent 3
836/// - Strategy 3 (strict JSON V2): Agent 1 → Agent 2 → Agent 3
837/// - etc.
838///
839/// This approach is more efficient because if a particular strategy works well
840/// with any agent, we succeed quickly rather than exhausting all strategies
841/// on the first agent before trying others.
842///
843/// # Arguments
844///
845/// * `diff` - The git diff to generate a commit message for
846/// * `registry` - The agent registry for resolving agents and fallbacks
847/// * `runtime` - The pipeline runtime for execution services
848/// * `commit_agent` - The primary agent to use for commit generation
849/// * `template_context` - Template context for user template overrides
850/// * `prompt_history` - Prompt history for checkpoint/resume determinism
851///
852/// # Returns
853///
854/// Returns `Ok(CommitMessageResult)` with the generated message and metadata.
855pub fn generate_commit_message(
856    diff: &str,
857    registry: &AgentRegistry,
858    runtime: &mut PipelineRuntime,
859    commit_agent: &str,
860    template_context: &crate::prompts::TemplateContext,
861    prompt_history: &HashMap<String, String>,
862) -> anyhow::Result<CommitMessageResult> {
863    let log_dir = ".agent/logs/commit_generation";
864    let log_file = format!("{log_dir}/final.log");
865
866    fs::create_dir_all(log_dir)?;
867    runtime.logger.info("Generating commit message...");
868
869    // Create a logging session for this commit generation run
870    let mut session = create_commit_log_session(log_dir, runtime);
871    let (working_diff, diff_was_pre_truncated) =
872        check_and_pre_truncate_diff(diff, commit_agent, runtime);
873
874    let fallbacks = registry.available_fallbacks(AgentRole::Commit);
875    let agents_to_try = build_agents_to_try(&fallbacks, commit_agent);
876
877    let mut last_extraction: Option<CommitExtractionResult> = None;
878    let mut total_attempts = 0;
879
880    // Generate a unique prompt key for this commit generation attempt
881    // Use timestamp-based key to ensure uniqueness across different commit generations
882    let prompt_key = format!(
883        "commit_{}",
884        std::time::SystemTime::now()
885            .duration_since(std::time::UNIX_EPOCH)
886            .unwrap_or_default()
887            .as_secs()
888    );
889
890    // Map to capture newly generated prompts for checkpoint/resume
891    let mut generated_prompts = std::collections::HashMap::new();
892
893    let mut attempt_ctx = CommitAttemptContext {
894        working_diff: &working_diff,
895        log_dir,
896        diff_was_truncated: diff_was_pre_truncated,
897        template_context,
898        prompt_history,
899        prompt_key,
900        generated_prompts: &mut generated_prompts,
901    };
902
903    // Try each agent with all prompt variants
904    if let Some(result) = try_agents_with_strategies(
905        &agents_to_try,
906        &mut attempt_ctx,
907        runtime,
908        registry,
909        &mut last_extraction,
910        &mut session,
911        &mut total_attempts,
912    ) {
913        log_completion(runtime, &session, total_attempts, &result);
914        // Include generated prompts in the result
915        return result.map(|mut r| {
916            r.generated_prompts = generated_prompts;
917            r
918        });
919    }
920
921    // Handle fallback cases
922    let fallback_ctx = CommitFallbackContext {
923        log_file: &log_file,
924    };
925    handle_commit_fallbacks(
926        &fallback_ctx,
927        runtime,
928        &session,
929        total_attempts,
930        last_extraction.as_ref(),
931        generated_prompts,
932    )
933}
934
935/// Create a commit log session, with fallback.
936fn create_commit_log_session(log_dir: &str, runtime: &mut PipelineRuntime) -> CommitLogSession {
937    match CommitLogSession::new(log_dir) {
938        Ok(s) => {
939            runtime.logger.info(&format!(
940                "Commit logs will be written to: {}",
941                s.run_dir().display()
942            ));
943            s
944        }
945        Err(e) => {
946            runtime
947                .logger
948                .warn(&format!("Failed to create log session: {e}"));
949            CommitLogSession::new(log_dir).unwrap_or_else(|_| {
950                CommitLogSession::new("/tmp/ralph-commit-logs").expect("fallback session")
951            })
952        }
953    }
954}
955
956/// Try all agents with their strategy variants.
957///
958/// This function implements strategy-first cycling:
959/// - Outer loop: Iterate through strategies
960/// - Inner loop: Try all agents with the current strategy
961/// - Only advance to next strategy if ALL agents failed with current strategy
962///
963/// This ensures each strategy gets the best chance to succeed with all
964/// available agents before we try degraded fallback prompts.
965fn try_agents_with_strategies(
966    agents: &[&str],
967    ctx: &mut CommitAttemptContext<'_>,
968    runtime: &mut PipelineRuntime,
969    registry: &AgentRegistry,
970    last_extraction: &mut Option<CommitExtractionResult>,
971    session: &mut CommitLogSession,
972    total_attempts: &mut usize,
973) -> Option<anyhow::Result<CommitMessageResult>> {
974    let mut strategy = CommitRetryStrategy::Normal;
975    loop {
976        runtime.logger.info(&format!(
977            "Trying strategy {}/{}: {}",
978            strategy.stage_number(),
979            CommitRetryStrategy::total_stages(),
980            strategy.description()
981        ));
982
983        for (agent_idx, agent) in agents.iter().enumerate() {
984            runtime.logger.info(&format!(
985                "  - Agent {}/{}: {agent}",
986                agent_idx + 1,
987                agents.len()
988            ));
989
990            *total_attempts += 1;
991            if let Some(result) = run_commit_attempt_with_agent(
992                strategy,
993                ctx,
994                runtime,
995                registry,
996                agent,
997                last_extraction,
998                session,
999            ) {
1000                return Some(result);
1001            }
1002        }
1003
1004        runtime.logger.warn(&format!(
1005            "All agents failed for strategy: {}",
1006            strategy.description()
1007        ));
1008
1009        match strategy.next() {
1010            Some(next) => strategy = next,
1011            None => break,
1012        }
1013    }
1014    None
1015}
1016
1017/// Log completion info and write session summary on success.
1018fn log_completion(
1019    runtime: &mut PipelineRuntime,
1020    session: &CommitLogSession,
1021    total_attempts: usize,
1022    result: &anyhow::Result<CommitMessageResult>,
1023) {
1024    if let Ok(ref commit_result) = result {
1025        let _ = session.write_summary(
1026            total_attempts,
1027            &format!(
1028                "SUCCESS: {}",
1029                preview_commit_message(&commit_result.message)
1030            ),
1031        );
1032    }
1033    runtime.logger.info(&format!(
1034        "Commit generation complete after {total_attempts} attempts. Logs: {}",
1035        session.run_dir().display()
1036    ));
1037}
1038
1039/// Context for commit fallback handling.
1040struct CommitFallbackContext<'a> {
1041    log_file: &'a str,
1042}
1043
1044/// Handle fallback cases after all agents exhausted.
1045///
1046/// With XSD validation handling everything, the fallback logic is simple:
1047/// - If we have a last extraction with a valid message, use it
1048/// - Otherwise, use the hardcoded fallback
1049fn handle_commit_fallbacks(
1050    ctx: &CommitFallbackContext<'_>,
1051    runtime: &mut PipelineRuntime,
1052    session: &CommitLogSession,
1053    total_attempts: usize,
1054    last_extraction: Option<&CommitExtractionResult>,
1055    generated_prompts: std::collections::HashMap<String, String>,
1056) -> anyhow::Result<CommitMessageResult> {
1057    // Use message from last extraction if available
1058    // (XSD validation already passed if we have an extraction)
1059    if let Some(extraction) = last_extraction {
1060        let message = extraction.clone().into_message();
1061        let _ = session.write_summary(
1062            total_attempts,
1063            &format!("LAST_EXTRACTION: {}", preview_commit_message(&message)),
1064        );
1065        runtime.logger.info(&format!(
1066            "Commit generation complete after {total_attempts} attempts. Logs: {}",
1067            session.run_dir().display()
1068        ));
1069        return Ok(CommitMessageResult {
1070            message,
1071            success: true,
1072            _log_path: ctx.log_file.to_string(),
1073            generated_prompts,
1074        });
1075    }
1076
1077    // Hardcoded fallback as last resort
1078    let _ = session.write_summary(
1079        total_attempts,
1080        &format!("HARDCODED_FALLBACK: {HARDCODED_FALLBACK_COMMIT}"),
1081    );
1082    runtime.logger.info(&format!(
1083        "Commit generation complete after {total_attempts} attempts (hardcoded fallback). Logs: {}",
1084        session.run_dir().display()
1085    ));
1086    Ok(return_hardcoded_fallback(
1087        ctx.log_file,
1088        runtime,
1089        generated_prompts,
1090    ))
1091}
1092
1093/// Create a commit with an automatically generated message using the standard pipeline.
1094///
1095/// This is a replacement for `commit_with_auto_message_fallback_result` in `git_helpers`
1096/// that uses the standard agent pipeline with proper logging and fallback support.
1097///
1098/// # Arguments
1099///
1100/// * `diff` - The git diff to generate a commit message from
1101/// * `commit_agent` - The primary agent to use for commit generation
1102/// * `git_user_name` - Optional git user name
1103/// * `git_user_email` - Optional git user email
1104/// * `ctx` - The phase context containing registry, logger, colors, config, and timer
1105///
1106/// # Returns
1107///
1108/// Returns `CommitResultFallback` indicating success, no changes, or failure.
1109pub fn commit_with_generated_message(
1110    diff: &str,
1111    commit_agent: &str,
1112    git_user_name: Option<&str>,
1113    git_user_email: Option<&str>,
1114    ctx: &mut PhaseContext<'_>,
1115) -> CommitResultFallback {
1116    // Stage all changes first
1117    let staged = match git_add_all() {
1118        Ok(s) => s,
1119        Err(e) => {
1120            return CommitResultFallback::Failed(format!("Failed to stage changes: {e}"));
1121        }
1122    };
1123
1124    if !staged {
1125        return CommitResultFallback::NoChanges;
1126    }
1127
1128    // Track execution start for commit generation
1129    let start_time = std::time::Instant::now();
1130
1131    // Set up the runtime
1132    let mut runtime = PipelineRuntime {
1133        timer: ctx.timer,
1134        logger: ctx.logger,
1135        colors: ctx.colors,
1136        config: ctx.config,
1137        #[cfg(any(test, feature = "test-utils"))]
1138        agent_executor: None,
1139    };
1140
1141    // Generate commit message using the standard pipeline
1142    let result = match generate_commit_message(
1143        diff,
1144        ctx.registry,
1145        &mut runtime,
1146        commit_agent,
1147        ctx.template_context,
1148        &ctx.prompt_history,
1149    ) {
1150        Ok(r) => r,
1151        Err(e) => {
1152            // Record failed generation in execution history
1153            ctx.execution_history.add_step(
1154                ExecutionStep::new(
1155                    "commit",
1156                    0,
1157                    "commit_generation",
1158                    StepOutcome::failure(format!("Failed to generate commit message: {e}"), false),
1159                )
1160                .with_agent(commit_agent)
1161                .with_duration(start_time.elapsed().as_secs()),
1162            );
1163            return CommitResultFallback::Failed(format!("Failed to generate commit message: {e}"));
1164        }
1165    };
1166
1167    // Capture generated prompts for checkpoint/resume
1168    for (key, prompt) in result.generated_prompts {
1169        ctx.capture_prompt(&key, &prompt);
1170    }
1171
1172    // Check if generation succeeded
1173    if !result.success || result.message.trim().is_empty() {
1174        // This should never happen after our fixes, but add defensive fallback
1175        ctx.logger
1176            .warn("Commit generation returned empty message, using hardcoded fallback...");
1177        let fallback_message = HARDCODED_FALLBACK_COMMIT.to_string();
1178        let commit_result = match git_commit(&fallback_message, git_user_name, git_user_email) {
1179            Ok(Some(oid)) => CommitResultFallback::Success(oid),
1180            Ok(None) => CommitResultFallback::NoChanges,
1181            Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1182        };
1183        // Record completion with fallback in execution history
1184        let outcome = match &commit_result {
1185            CommitResultFallback::Success(oid) => StepOutcome::success(
1186                Some(format!("Commit created: {oid}")),
1187                vec![".".to_string()],
1188            ),
1189            CommitResultFallback::NoChanges => {
1190                StepOutcome::skipped("No changes to commit".to_string())
1191            }
1192            CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1193        };
1194        ctx.execution_history.add_step(
1195            ExecutionStep::new("commit", 0, "commit_generation", outcome)
1196                .with_agent(commit_agent)
1197                .with_duration(start_time.elapsed().as_secs()),
1198        );
1199        commit_result
1200    } else {
1201        // Create the commit with the generated message
1202        let commit_result = match git_commit(&result.message, git_user_name, git_user_email) {
1203            Ok(Some(oid)) => CommitResultFallback::Success(oid),
1204            Ok(None) => CommitResultFallback::NoChanges,
1205            Err(e) => CommitResultFallback::Failed(format!("Failed to create commit: {e}")),
1206        };
1207        // Record completion in execution history
1208        let outcome = match &commit_result {
1209            CommitResultFallback::Success(oid) => StepOutcome::success(
1210                Some(format!("Commit created: {oid}")),
1211                vec![".".to_string()],
1212            ),
1213            CommitResultFallback::NoChanges => {
1214                StepOutcome::skipped("No changes to commit".to_string())
1215            }
1216            CommitResultFallback::Failed(e) => StepOutcome::failure(e.clone(), false),
1217        };
1218        let oid_for_history = match &commit_result {
1219            CommitResultFallback::Success(oid) => Some(oid.to_string()),
1220            _ => None,
1221        };
1222        let mut step = ExecutionStep::new("commit", 0, "commit_generation", outcome)
1223            .with_agent(commit_agent)
1224            .with_duration(start_time.elapsed().as_secs());
1225        if let Some(ref oid) = oid_for_history {
1226            step = step.with_git_commit_oid(oid);
1227        }
1228        ctx.execution_history.add_step(step);
1229        commit_result
1230    }
1231}
1232
1233/// Import types needed for parsing trace helpers.
1234use crate::phases::commit_logging::{ParsingTraceLog, ParsingTraceStep};
1235
1236/// Write parsing trace log to file with error handling.
1237fn write_parsing_trace_with_logging(
1238    parsing_trace: &ParsingTraceLog,
1239    log_dir: &str,
1240    logger: &Logger,
1241) {
1242    if let Err(e) = parsing_trace.write_to_file(std::path::Path::new(log_dir)) {
1243        logger.warn(&format!("Failed to write parsing trace log: {e}"));
1244    }
1245}
1246
1247/// Try XML extraction and record in parsing trace.
1248/// Returns `Some(result)` if extraction succeeded (XSD validation passed), `None` otherwise.
1249fn try_xml_extraction_traced(
1250    content: &str,
1251    step_number: &mut usize,
1252    parsing_trace: &mut ParsingTraceLog,
1253    logger: &Logger,
1254    attempt_log: &mut CommitAttemptLog,
1255    log_dir: &str,
1256) -> Option<CommitExtractionResult> {
1257    let (xml_result, xml_detail) = try_extract_xml_commit_with_trace(content);
1258    logger.info(&format!("  ✓ XML extraction: {xml_detail}"));
1259
1260    parsing_trace.add_step(
1261        ParsingTraceStep::new(*step_number, "XML Extraction")
1262            .with_input(&content[..content.len().min(1000)])
1263            .with_result(xml_result.as_deref().unwrap_or("[No XML found]"))
1264            .with_success(xml_result.is_some())
1265            .with_details(&xml_detail),
1266    );
1267    *step_number += 1;
1268
1269    if let Some(message) = xml_result {
1270        // XSD validation already passed inside try_extract_xml_commit_with_trace
1271        attempt_log.add_extraction_attempt(ExtractionAttempt::success("XML", xml_detail));
1272        parsing_trace.set_final_message(&message);
1273        write_parsing_trace_with_logging(parsing_trace, log_dir, logger);
1274        return Some(CommitExtractionResult::new(message));
1275    }
1276
1277    // XML extraction or XSD validation failed
1278    attempt_log.add_extraction_attempt(ExtractionAttempt::failure("XML", xml_detail));
1279    logger.info("  ✗ XML extraction failed");
1280    None
1281}
1282
1283/// Extract a commit message from agent logs with full tracing for diagnostics.
1284///
1285/// Records all extraction attempts in the provided `CommitAttemptLog` for debugging.
1286///
1287/// This function also creates and writes a `ParsingTraceLog` that captures
1288/// detailed information about each extraction step, including the exact
1289/// content being processed and XSD validation results.
1290fn extract_commit_message_from_logs_with_trace(
1291    log_dir: &str,
1292    _diff: &str,
1293    _agent_cmd: &str,
1294    logger: &Logger,
1295    attempt_log: &mut CommitAttemptLog,
1296) -> anyhow::Result<Option<CommitExtractionResult>> {
1297    // Create parsing trace log
1298    let mut parsing_trace = ParsingTraceLog::new(
1299        attempt_log.attempt_number,
1300        &attempt_log.agent,
1301        &attempt_log.strategy,
1302    );
1303
1304    // Read and preprocess log content
1305    let Some(content) = read_log_content_with_trace(log_dir, logger, attempt_log)? else {
1306        return Ok(None);
1307    };
1308
1309    // Set raw output in parsing trace
1310    parsing_trace.set_raw_output(&content);
1311
1312    let mut step_number = 1;
1313
1314    // XML-only extraction with XSD validation
1315    // The XML extraction includes flexible parsing with 4 strategies and XSD validation
1316    // If XSD validation fails, the error is returned for in-session retry
1317    if let Some(result) = try_xml_extraction_traced(
1318        &content,
1319        &mut step_number,
1320        &mut parsing_trace,
1321        logger,
1322        attempt_log,
1323        log_dir,
1324    ) {
1325        return Ok(Some(result));
1326    }
1327
1328    // XML extraction failed - add final failure step to parsing trace
1329    parsing_trace.add_step(
1330        ParsingTraceStep::new(step_number, "XML Extraction Failed")
1331            .with_input(&content[..content.len().min(1000)])
1332            .with_success(false)
1333            .with_details("No valid XML found or XSD validation failed"),
1334    );
1335
1336    write_parsing_trace_with_logging(&parsing_trace, log_dir, logger);
1337
1338    // Return None to trigger next strategy/agent fallback
1339    // The in-session retry loop will have already attempted XSD validation retries
1340    // if the error was an XSD validation failure (detected in attempt_log)
1341    Ok(None)
1342}
1343
1344/// Read and preprocess log content for extraction.
1345fn read_log_content_with_trace(
1346    log_dir: &str,
1347    logger: &Logger,
1348    attempt_log: &mut CommitAttemptLog,
1349) -> anyhow::Result<Option<String>> {
1350    let log_path = find_most_recent_log(log_dir)?;
1351    let Some(log_file) = log_path else {
1352        logger.warn("No log files found in commit generation directory");
1353        attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1354            "File",
1355            "No log files found".to_string(),
1356        ));
1357        return Ok(None);
1358    };
1359
1360    logger.info(&format!(
1361        "Reading commit message from log: {}",
1362        log_file.display()
1363    ));
1364
1365    let mut content = String::new();
1366    let mut file = File::open(&log_file)?;
1367    file.read_to_string(&mut content)?;
1368    attempt_log.set_raw_output(&content);
1369
1370    if content.trim().is_empty() {
1371        logger.warn("Log file is empty");
1372        attempt_log.add_extraction_attempt(ExtractionAttempt::failure(
1373            "File",
1374            "Log file is empty".to_string(),
1375        ));
1376        return Ok(None);
1377    }
1378
1379    // Apply preprocessing
1380    Ok(Some(preprocess_raw_content(&content)))
1381}
1382
1383/// Find the most recently modified log file in a directory or matching a prefix pattern.
1384///
1385/// Supports two modes:
1386/// 1. **Directory mode**: If `log_path` is a directory, find the most recent `.log` file in it
1387/// 2. **Prefix mode**: If `log_path` is not a directory, treat it as a prefix pattern and
1388///    search for files matching `{prefix}*.log` in the parent directory
1389///
1390/// # Arguments
1391///
1392/// * `log_path` - Either a directory path or a prefix pattern for log files
1393///
1394/// # Returns
1395///
1396/// * `Ok(Some(path))` - Path to the most recent log file
1397/// * `Ok(None)` - No log files found
1398/// * `Err(e)` - Error reading directory
1399fn find_most_recent_log(log_path: &str) -> anyhow::Result<Option<std::path::PathBuf>> {
1400    let path = std::path::PathBuf::from(log_path);
1401
1402    // Mode 1: If path is a directory, search for .log files with empty prefix (matches all)
1403    if path.is_dir() {
1404        return find_most_recent_log_with_prefix(&path, "");
1405    }
1406
1407    // Mode 2: Prefix pattern mode - search parent directory for files starting with the prefix
1408    let parent_dir = match path.parent() {
1409        Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
1410        _ => std::path::PathBuf::from("."),
1411    };
1412
1413    if !parent_dir.exists() {
1414        return Ok(None);
1415    }
1416
1417    let base_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
1418
1419    find_most_recent_log_with_prefix(&parent_dir, base_name)
1420}
1421
1422/// Find the most recently modified log file matching a prefix pattern.
1423///
1424/// Only matches `.log` files that start with `prefix`. If `prefix` is empty,
1425/// matches any `.log` file in the directory.
1426fn find_most_recent_log_with_prefix(
1427    dir: &std::path::Path,
1428    prefix: &str,
1429) -> anyhow::Result<Option<std::path::PathBuf>> {
1430    if !dir.exists() {
1431        return Ok(None);
1432    }
1433
1434    let entries = fs::read_dir(dir)?;
1435    let mut most_recent: Option<(std::path::PathBuf, std::time::SystemTime)> = None;
1436
1437    for entry in entries.flatten() {
1438        let path = entry.path();
1439
1440        // Only look at .log files that start with the prefix (or any .log file if prefix is empty)
1441        if let Some(file_name) = path.file_name().and_then(|s| s.to_str()) {
1442            if !file_name.starts_with(prefix)
1443                || path.extension().and_then(|s| s.to_str()) != Some("log")
1444            {
1445                continue;
1446            }
1447        } else {
1448            continue;
1449        }
1450
1451        if let Ok(metadata) = entry.metadata() {
1452            if let Ok(modified) = metadata.modified() {
1453                match &most_recent {
1454                    None => {
1455                        most_recent = Some((path, modified));
1456                    }
1457                    Some((_, prev_modified)) if modified > *prev_modified => {
1458                        most_recent = Some((path, modified));
1459                    }
1460                    _ => {}
1461                }
1462            }
1463        }
1464    }
1465
1466    Ok(most_recent.map(|(path, _)| path))
1467}
1468
1469#[cfg(test)]
1470mod tests {
1471    use super::*;
1472
1473    #[test]
1474    fn test_find_most_recent_log() {
1475        // Test with non-existent directory
1476        let result = find_most_recent_log("/nonexistent/path");
1477        assert!(result.is_ok());
1478        assert!(result.unwrap().is_none());
1479    }
1480
1481    #[test]
1482    fn test_truncate_diff_if_large() {
1483        let large_diff = "a".repeat(100_000);
1484        let truncated = truncate_diff_if_large(&large_diff, 10_000);
1485
1486        // Should be truncated
1487        assert!(truncated.len() < large_diff.len());
1488    }
1489
1490    #[test]
1491    fn test_truncate_preserves_small_diffs() {
1492        let small_diff = "a".repeat(100);
1493        let truncated = truncate_diff_if_large(&small_diff, 10_000);
1494
1495        // Should not be modified
1496        assert_eq!(truncated, small_diff);
1497    }
1498
1499    #[test]
1500    fn test_truncate_exactly_at_limit() {
1501        let diff = "a".repeat(10_000);
1502        let truncated = truncate_diff_if_large(&diff, 10_000);
1503
1504        // Should not be modified when at exact limit
1505        assert_eq!(truncated, diff);
1506    }
1507
1508    #[test]
1509    fn test_truncate_preserves_file_boundaries() {
1510        let diff = "diff --git a/file1.rs b/file1.rs\n\
1511            +line1\n\
1512            +line2\n\
1513            diff --git a/file2.rs b/file2.rs\n\
1514            +line3\n\
1515            +line4\n";
1516        let large_diff = format!("{}{}", diff, "x".repeat(100_000));
1517        let truncated = truncate_diff_if_large(&large_diff, 50);
1518
1519        // Should preserve complete file blocks
1520        assert!(truncated.contains("diff --git"));
1521        // Should contain truncation summary
1522        assert!(truncated.contains("Diff truncated"));
1523    }
1524
1525    #[test]
1526    fn test_prioritize_file_path() {
1527        // Source files get highest priority
1528        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("tests/test.rs"));
1529        assert!(prioritize_file_path("src/lib.rs") > prioritize_file_path("README.md"));
1530
1531        // Tests get lower priority than src
1532        assert!(prioritize_file_path("src/main.rs") > prioritize_file_path("test/test.rs"));
1533
1534        // Config files get medium priority
1535        assert!(prioritize_file_path("Cargo.toml") > prioritize_file_path("docs/guide.md"));
1536
1537        // Docs get lowest priority
1538        assert!(prioritize_file_path("README.md") < prioritize_file_path("src/main.rs"));
1539    }
1540
1541    #[test]
1542    fn test_truncate_keeps_high_priority_files() {
1543        let diff = "diff --git a/README.md b/README.md\n\
1544            +doc change\n\
1545            diff --git a/src/main.rs b/src/main.rs\n\
1546            +important change\n\
1547            diff --git a/tests/test.rs b/tests/test.rs\n\
1548            +test change\n";
1549
1550        // With a very small limit, should keep src/main.rs first
1551        let truncated = truncate_diff_if_large(diff, 80);
1552
1553        // Should include the high priority src file
1554        assert!(truncated.contains("src/main.rs"));
1555    }
1556
1557    #[test]
1558    fn test_truncate_lines_to_fit() {
1559        let lines = vec![
1560            "line1".to_string(),
1561            "line2".to_string(),
1562            "line3".to_string(),
1563            "line4".to_string(),
1564        ];
1565
1566        // Only fit first 3 lines (each 5 chars + newline = 6)
1567        let truncated = truncate_lines_to_fit(&lines, 18);
1568
1569        assert_eq!(truncated.len(), 3);
1570        // Last line should have truncation marker
1571        assert!(truncated[2].ends_with("[truncated...]"));
1572    }
1573
1574    #[test]
1575    fn test_hardcoded_fallback_commit() {
1576        // The hardcoded fallback should always be a valid conventional commit
1577        use crate::files::llm_output_extraction::is_conventional_commit_subject;
1578        assert!(
1579            is_conventional_commit_subject(HARDCODED_FALLBACK_COMMIT),
1580            "Hardcoded fallback must be a valid conventional commit"
1581        );
1582        assert!(!HARDCODED_FALLBACK_COMMIT.is_empty());
1583        assert!(HARDCODED_FALLBACK_COMMIT.len() >= 5);
1584    }
1585}