Skip to main content

ralph_workflow/phases/
commit_logging.rs

1//! Per-attempt logging infrastructure for commit message generation.
2//!
3//! This module provides detailed logging for each commit generation attempt,
4//! creating a clear audit trail for debugging parsing failures. Each attempt
5//! produces a unique numbered log file that captures:
6//! - Prompt information
7//! - Raw agent output
8//! - All extraction attempts with reasons
9//! - Validation results
10//! - Final outcome
11//!
12//! Log files are organized by session to prevent overwrites and allow
13//! comparison across multiple attempts.
14
15use chrono::{DateTime, Local};
16use std::path::{Path, PathBuf};
17
18use crate::workspace::Workspace;
19
20/// Represents a single step in the parsing trace log.
21#[derive(Debug, Clone)]
22pub struct ParsingTraceStep {
23    /// Step number in the trace
24    pub step_number: usize,
25    /// Description of what was attempted
26    pub description: String,
27    /// Input content for this step
28    pub input: Option<String>,
29    /// Result/output of this step
30    pub result: Option<String>,
31    /// Whether this step succeeded
32    pub success: bool,
33    /// Additional details or error message
34    pub details: String,
35}
36
37impl ParsingTraceStep {
38    /// Create a new parsing trace step.
39    pub fn new(step_number: usize, description: &str) -> Self {
40        Self {
41            step_number,
42            description: description.to_string(),
43            input: None,
44            result: None,
45            success: false,
46            details: String::new(),
47        }
48    }
49
50    /// Set the input for this step.
51    pub fn with_input(mut self, input: &str) -> Self {
52        // Truncate input if too large
53        const MAX_INPUT_SIZE: usize = 10_000;
54        self.input = if input.len() > MAX_INPUT_SIZE {
55            Some(format!(
56                "{}\n\n[... input truncated {} bytes ...]",
57                &input[..MAX_INPUT_SIZE / 2],
58                input.len() - MAX_INPUT_SIZE
59            ))
60        } else {
61            Some(input.to_string())
62        };
63        self
64    }
65
66    /// Set the result for this step.
67    pub fn with_result(mut self, result: &str) -> Self {
68        // Truncate result if too large
69        const MAX_RESULT_SIZE: usize = 10_000;
70        self.result = if result.len() > MAX_RESULT_SIZE {
71            Some(format!(
72                "{}\n\n[... result truncated {} bytes ...]",
73                &result[..MAX_RESULT_SIZE / 2],
74                result.len() - MAX_RESULT_SIZE
75            ))
76        } else {
77            Some(result.to_string())
78        };
79        self
80    }
81
82    /// Set whether this step succeeded.
83    pub const fn with_success(mut self, success: bool) -> Self {
84        self.success = success;
85        self
86    }
87
88    /// Set additional details.
89    pub fn with_details(mut self, details: &str) -> Self {
90        self.details = details.to_string();
91        self
92    }
93}
94
95/// Detailed parsing trace log for commit message extraction.
96///
97/// This log captures each step of the extraction process, showing:
98/// - What extraction method was tried (XML, JSON, pattern-based)
99/// - The exact content being processed at each step
100/// - Validation results and why they passed/failed
101/// - The final extracted message
102///
103/// This is separate from the attempt log and written to `parsing_trace.log`.
104#[derive(Debug, Clone)]
105pub struct ParsingTraceLog {
106    /// Attempt number this trace belongs to
107    pub attempt_number: usize,
108    /// Agent that generated the output
109    pub agent: String,
110    /// Strategy used
111    pub strategy: String,
112    /// Raw output from the agent
113    pub raw_output: Option<String>,
114    /// Individual parsing steps
115    pub steps: Vec<ParsingTraceStep>,
116    /// Final extracted message (if any)
117    pub final_message: Option<String>,
118    /// Timestamp when trace started
119    pub timestamp: DateTime<Local>,
120}
121
122impl ParsingTraceLog {
123    /// Create a new parsing trace log.
124    pub fn new(attempt_number: usize, agent: &str, strategy: &str) -> Self {
125        Self {
126            attempt_number,
127            agent: agent.to_string(),
128            strategy: strategy.to_string(),
129            raw_output: None,
130            steps: Vec::new(),
131            final_message: None,
132            timestamp: Local::now(),
133        }
134    }
135
136    /// Set the raw output from the agent.
137    pub fn set_raw_output(&mut self, output: &str) {
138        const MAX_OUTPUT_SIZE: usize = 50_000;
139        self.raw_output = if output.len() > MAX_OUTPUT_SIZE {
140            Some(format!(
141                "{}\n\n[... raw output truncated {} bytes ...]\n\n{}",
142                &output[..MAX_OUTPUT_SIZE / 2],
143                output.len() - MAX_OUTPUT_SIZE,
144                &output[output.len() - MAX_OUTPUT_SIZE / 2..]
145            ))
146        } else {
147            Some(output.to_string())
148        };
149    }
150
151    /// Add a parsing step to the trace.
152    pub fn add_step(&mut self, step: ParsingTraceStep) {
153        self.steps.push(step);
154    }
155
156    /// Set the final extracted message.
157    pub fn set_final_message(&mut self, message: &str) {
158        self.final_message = Some(message.to_string());
159    }
160
161    /// Write this trace to a file using workspace abstraction.
162    ///
163    /// This is the architecture-conformant version that uses the workspace trait
164    /// instead of direct filesystem access.
165    ///
166    /// # Arguments
167    ///
168    /// * `log_dir` - Directory to write the trace file to (relative to workspace)
169    /// * `workspace` - The workspace to use for filesystem operations
170    ///
171    /// # Returns
172    ///
173    /// Path to the written trace file on success.
174    pub fn write_to_workspace(
175        &self,
176        log_dir: &Path,
177        workspace: &dyn Workspace,
178    ) -> std::io::Result<PathBuf> {
179        let trace_path = log_dir.join(format!(
180            "attempt_{:03}_parsing_trace.log",
181            self.attempt_number
182        ));
183
184        // Build the content in memory first
185        let mut content = String::new();
186        Self::write_header_to_string(&mut content, self);
187        Self::write_raw_output_to_string(&mut content, self);
188        Self::write_parsing_steps_to_string(&mut content, self);
189        Self::write_final_message_to_string(&mut content, self);
190        Self::write_footer_to_string(&mut content);
191
192        // Write using workspace
193        workspace.create_dir_all(log_dir)?;
194        workspace.write(&trace_path, &content)?;
195
196        Ok(trace_path)
197    }
198
199    // String-based write helpers for workspace support
200    fn write_header_to_string(s: &mut String, trace: &Self) {
201        use std::fmt::Write;
202        let _ = writeln!(
203            s,
204            "================================================================================"
205        );
206        let _ = writeln!(
207            s,
208            "PARSING TRACE LOG - Attempt #{:03}",
209            trace.attempt_number
210        );
211        let _ = writeln!(
212            s,
213            "================================================================================"
214        );
215        let _ = writeln!(s);
216        let _ = writeln!(s, "Agent:     {}", trace.agent);
217        let _ = writeln!(s, "Strategy:  {}", trace.strategy);
218        let _ = writeln!(
219            s,
220            "Timestamp: {}",
221            trace.timestamp.format("%Y-%m-%d %H:%M:%S %Z")
222        );
223        let _ = writeln!(s);
224    }
225
226    fn write_raw_output_to_string(s: &mut String, trace: &Self) {
227        use std::fmt::Write;
228        let _ = writeln!(
229            s,
230            "--------------------------------------------------------------------------------"
231        );
232        let _ = writeln!(s, "RAW AGENT OUTPUT");
233        let _ = writeln!(
234            s,
235            "--------------------------------------------------------------------------------"
236        );
237        let _ = writeln!(s);
238        match &trace.raw_output {
239            Some(output) => {
240                let _ = writeln!(s, "{output}");
241            }
242            None => {
243                let _ = writeln!(s, "[No raw output captured]");
244            }
245        }
246        let _ = writeln!(s);
247    }
248
249    fn write_parsing_steps_to_string(s: &mut String, trace: &Self) {
250        use std::fmt::Write;
251        let _ = writeln!(
252            s,
253            "--------------------------------------------------------------------------------"
254        );
255        let _ = writeln!(s, "PARSING STEPS");
256        let _ = writeln!(
257            s,
258            "--------------------------------------------------------------------------------"
259        );
260        let _ = writeln!(s);
261
262        if trace.steps.is_empty() {
263            let _ = writeln!(s, "[No parsing steps recorded]");
264        } else {
265            for step in &trace.steps {
266                let status = if step.success {
267                    "✓ SUCCESS"
268                } else {
269                    "✗ FAILED"
270                };
271                let _ = writeln!(s, "{}. {} [{}]", step.step_number, step.description, status);
272                let _ = writeln!(s);
273
274                if let Some(input) = &step.input {
275                    let _ = writeln!(s, "   INPUT:");
276                    for line in input.lines() {
277                        let _ = writeln!(s, "   {line}");
278                    }
279                    let _ = writeln!(s);
280                }
281
282                if let Some(result) = &step.result {
283                    let _ = writeln!(s, "   RESULT:");
284                    for line in result.lines() {
285                        let _ = writeln!(s, "   {line}");
286                    }
287                    let _ = writeln!(s);
288                }
289
290                if !step.details.is_empty() {
291                    let _ = writeln!(s, "   DETAILS: {}", step.details);
292                    let _ = writeln!(s);
293                }
294            }
295        }
296        let _ = writeln!(s);
297    }
298
299    fn write_final_message_to_string(s: &mut String, trace: &Self) {
300        use std::fmt::Write;
301        let _ = writeln!(
302            s,
303            "--------------------------------------------------------------------------------"
304        );
305        let _ = writeln!(s, "FINAL EXTRACTED MESSAGE");
306        let _ = writeln!(
307            s,
308            "--------------------------------------------------------------------------------"
309        );
310        let _ = writeln!(s);
311        match &trace.final_message {
312            Some(message) => {
313                let _ = writeln!(s, "{message}");
314            }
315            None => {
316                let _ = writeln!(s, "[No message extracted]");
317            }
318        }
319        let _ = writeln!(s);
320    }
321
322    fn write_footer_to_string(s: &mut String) {
323        use std::fmt::Write;
324        let _ = writeln!(
325            s,
326            "================================================================================"
327        );
328    }
329}
330
331/// Represents an extraction attempt with its method and outcome.
332#[derive(Debug, Clone)]
333pub struct ExtractionAttempt {
334    /// Name of the extraction method (e.g., "XML", "JSON", "Salvage")
335    pub method: &'static str,
336    /// Whether this method succeeded
337    pub success: bool,
338    /// Detailed reason/description of what happened
339    pub detail: String,
340}
341
342impl ExtractionAttempt {
343    /// Create a successful extraction attempt.
344    pub const fn success(method: &'static str, detail: String) -> Self {
345        Self {
346            method,
347            success: true,
348            detail,
349        }
350    }
351
352    /// Create a failed extraction attempt.
353    pub const fn failure(method: &'static str, detail: String) -> Self {
354        Self {
355            method,
356            success: false,
357            detail,
358        }
359    }
360}
361
362/// Represents a single validation check result.
363#[derive(Debug, Clone)]
364pub struct ValidationCheck {
365    /// Name of the validation check
366    pub name: &'static str,
367    /// Whether this check passed
368    pub passed: bool,
369    /// Error message if check failed
370    pub error: Option<String>,
371}
372
373impl ValidationCheck {
374    /// Create a passing validation check.
375    #[cfg(test)]
376    pub const fn pass(name: &'static str) -> Self {
377        Self {
378            name,
379            passed: true,
380            error: None,
381        }
382    }
383
384    /// Create a failing validation check.
385    #[cfg(test)]
386    pub const fn fail(name: &'static str, error: String) -> Self {
387        Self {
388            name,
389            passed: false,
390            error: Some(error),
391        }
392    }
393}
394
395/// Outcome of a commit generation attempt.
396#[derive(Debug, Clone)]
397pub enum AttemptOutcome {
398    /// Successfully extracted a valid commit message
399    Success(String),
400    /// XSD validation failed with specific error message
401    XsdValidationFailed(String),
402    /// Extraction failed entirely
403    ExtractionFailed(String),
404}
405
406impl std::fmt::Display for AttemptOutcome {
407    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
408        match self {
409            Self::Success(msg) => write!(f, "SUCCESS: {}", preview_message(msg)),
410            Self::XsdValidationFailed(err) => write!(f, "XSD_VALIDATION_FAILED: {err}"),
411            Self::ExtractionFailed(err) => write!(f, "EXTRACTION_FAILED: {err}"),
412        }
413    }
414}
415
416/// Preview a message, truncating if too long.
417fn preview_message(msg: &str) -> String {
418    let first_line = msg.lines().next().unwrap_or(msg);
419    if first_line.len() > 60 {
420        format!("{}...", &first_line[..60])
421    } else {
422        first_line.to_string()
423    }
424}
425
426/// Per-attempt log for commit message generation.
427///
428/// Captures all details about a single attempt to generate a commit message,
429/// providing a complete audit trail for debugging.
430#[derive(Debug, Clone)]
431pub struct CommitAttemptLog {
432    /// Attempt number within this session
433    pub attempt_number: usize,
434    /// Agent being used (e.g., "claude", "glm")
435    pub agent: String,
436    /// Retry strategy (e.g., "initial", "`strict_json`")
437    pub strategy: String,
438    /// Timestamp when attempt started
439    pub timestamp: DateTime<Local>,
440    /// Size of the prompt in bytes
441    pub prompt_size_bytes: usize,
442    /// Size of the diff in bytes
443    pub diff_size_bytes: usize,
444    /// Whether the diff was pre-truncated
445    pub diff_was_truncated: bool,
446    /// Raw output from the agent (truncated if very large)
447    pub raw_output: Option<String>,
448    /// Extraction attempts with their results
449    pub extraction_attempts: Vec<ExtractionAttempt>,
450    /// Validation checks that were run
451    pub validation_checks: Vec<ValidationCheck>,
452    /// Final outcome of this attempt
453    pub outcome: Option<AttemptOutcome>,
454}
455
456impl CommitAttemptLog {
457    /// Create a new attempt log.
458    pub fn new(attempt_number: usize, agent: &str, strategy: &str) -> Self {
459        Self {
460            attempt_number,
461            agent: agent.to_string(),
462            strategy: strategy.to_string(),
463            timestamp: Local::now(),
464            prompt_size_bytes: 0,
465            diff_size_bytes: 0,
466            diff_was_truncated: false,
467            raw_output: None,
468            extraction_attempts: Vec::new(),
469            validation_checks: Vec::new(),
470            outcome: None,
471        }
472    }
473
474    /// Set the prompt size.
475    pub const fn set_prompt_size(&mut self, size: usize) {
476        self.prompt_size_bytes = size;
477    }
478
479    /// Set the diff information.
480    pub const fn set_diff_info(&mut self, size: usize, was_truncated: bool) {
481        self.diff_size_bytes = size;
482        self.diff_was_truncated = was_truncated;
483    }
484
485    /// Set the raw output from the agent.
486    ///
487    /// Truncates very large outputs to prevent log file bloat.
488    pub fn set_raw_output(&mut self, output: &str) {
489        const MAX_OUTPUT_SIZE: usize = 50_000;
490        if output.len() > MAX_OUTPUT_SIZE {
491            self.raw_output = Some(format!(
492                "{}\n\n[... truncated {} bytes ...]\n\n{}",
493                &output[..MAX_OUTPUT_SIZE / 2],
494                output.len() - MAX_OUTPUT_SIZE,
495                &output[output.len() - MAX_OUTPUT_SIZE / 2..]
496            ));
497        } else {
498            self.raw_output = Some(output.to_string());
499        }
500    }
501
502    /// Record an extraction attempt.
503    pub fn add_extraction_attempt(&mut self, attempt: ExtractionAttempt) {
504        self.extraction_attempts.push(attempt);
505    }
506
507    /// Record validation check results.
508    #[cfg(test)]
509    pub fn set_validation_checks(&mut self, checks: Vec<ValidationCheck>) {
510        self.validation_checks = checks;
511    }
512
513    /// Set the final outcome.
514    pub fn set_outcome(&mut self, outcome: AttemptOutcome) {
515        self.outcome = Some(outcome);
516    }
517
518    /// Write this log to a file using workspace abstraction.
519    ///
520    /// This is the architecture-conformant version that uses the workspace trait
521    /// instead of direct filesystem access.
522    ///
523    /// # Arguments
524    ///
525    /// * `log_dir` - Directory to write the log file to (relative to workspace)
526    /// * `workspace` - The workspace to use for filesystem operations
527    ///
528    /// # Returns
529    ///
530    /// Path to the written log file on success.
531    pub fn write_to_workspace(
532        &self,
533        log_dir: &Path,
534        workspace: &dyn Workspace,
535    ) -> std::io::Result<PathBuf> {
536        // Create the log directory if needed
537        workspace.create_dir_all(log_dir)?;
538
539        // Generate filename
540        let filename = format!(
541            "attempt_{:03}_{}_{}_{}.log",
542            self.attempt_number,
543            sanitize_agent_name(&self.agent),
544            self.strategy.replace(' ', "_"),
545            self.timestamp.format("%Y%m%dT%H%M%S")
546        );
547        let log_path = log_dir.join(filename);
548
549        // Build content in memory
550        let mut content = String::new();
551        self.write_header_to_string(&mut content);
552        self.write_context_to_string(&mut content);
553        self.write_raw_output_to_string(&mut content);
554        self.write_extraction_attempts_to_string(&mut content);
555        self.write_validation_to_string(&mut content);
556        self.write_outcome_to_string(&mut content);
557
558        // Write using workspace
559        workspace.write(&log_path, &content)?;
560        Ok(log_path)
561    }
562
563    // String-based write helpers for workspace support
564    fn write_header_to_string(&self, s: &mut String) {
565        use std::fmt::Write;
566        let _ = writeln!(
567            s,
568            "========================================================================"
569        );
570        let _ = writeln!(s, "COMMIT GENERATION ATTEMPT LOG");
571        let _ = writeln!(
572            s,
573            "========================================================================"
574        );
575        let _ = writeln!(s);
576        let _ = writeln!(s, "Attempt:   #{}", self.attempt_number);
577        let _ = writeln!(s, "Agent:     {}", self.agent);
578        let _ = writeln!(s, "Strategy:  {}", self.strategy);
579        let _ = writeln!(
580            s,
581            "Timestamp: {}",
582            self.timestamp.format("%Y-%m-%d %H:%M:%S %Z")
583        );
584        let _ = writeln!(s);
585    }
586
587    fn write_context_to_string(&self, s: &mut String) {
588        use std::fmt::Write;
589        let _ = writeln!(
590            s,
591            "------------------------------------------------------------------------"
592        );
593        let _ = writeln!(s, "CONTEXT");
594        let _ = writeln!(
595            s,
596            "------------------------------------------------------------------------"
597        );
598        let _ = writeln!(s);
599        let _ = writeln!(
600            s,
601            "Prompt size: {} bytes ({} KB)",
602            self.prompt_size_bytes,
603            self.prompt_size_bytes / 1024
604        );
605        let _ = writeln!(
606            s,
607            "Diff size:   {} bytes ({} KB)",
608            self.diff_size_bytes,
609            self.diff_size_bytes / 1024
610        );
611        let _ = writeln!(
612            s,
613            "Diff truncated: {}",
614            if self.diff_was_truncated { "YES" } else { "NO" }
615        );
616        let _ = writeln!(s);
617    }
618
619    fn write_raw_output_to_string(&self, s: &mut String) {
620        use std::fmt::Write;
621        let _ = writeln!(
622            s,
623            "------------------------------------------------------------------------"
624        );
625        let _ = writeln!(s, "RAW AGENT OUTPUT");
626        let _ = writeln!(
627            s,
628            "------------------------------------------------------------------------"
629        );
630        let _ = writeln!(s);
631        match &self.raw_output {
632            Some(output) => {
633                let _ = writeln!(s, "{output}");
634            }
635            None => {
636                let _ = writeln!(s, "[No output captured]");
637            }
638        }
639        let _ = writeln!(s);
640    }
641
642    fn write_extraction_attempts_to_string(&self, s: &mut String) {
643        use std::fmt::Write;
644        let _ = writeln!(
645            s,
646            "------------------------------------------------------------------------"
647        );
648        let _ = writeln!(s, "EXTRACTION ATTEMPTS");
649        let _ = writeln!(
650            s,
651            "------------------------------------------------------------------------"
652        );
653        let _ = writeln!(s);
654
655        if self.extraction_attempts.is_empty() {
656            let _ = writeln!(s, "[No extraction attempts recorded]");
657        } else {
658            for (i, attempt) in self.extraction_attempts.iter().enumerate() {
659                let status = if attempt.success {
660                    "✓ SUCCESS"
661                } else {
662                    "✗ FAILED"
663                };
664                let _ = writeln!(s, "{}. {} [{}]", i + 1, attempt.method, status);
665                let _ = writeln!(s, "   Detail: {}", attempt.detail);
666                let _ = writeln!(s);
667            }
668        }
669        let _ = writeln!(s);
670    }
671
672    fn write_validation_to_string(&self, s: &mut String) {
673        use std::fmt::Write;
674        let _ = writeln!(
675            s,
676            "------------------------------------------------------------------------"
677        );
678        let _ = writeln!(s, "VALIDATION RESULTS");
679        let _ = writeln!(
680            s,
681            "------------------------------------------------------------------------"
682        );
683        let _ = writeln!(s);
684
685        if self.validation_checks.is_empty() {
686            let _ = writeln!(s, "[No validation checks recorded]");
687        } else {
688            for check in &self.validation_checks {
689                let status = if check.passed { "✓ PASS" } else { "✗ FAIL" };
690                let _ = write!(s, "  [{status}] {}", check.name);
691                if let Some(error) = &check.error {
692                    let _ = writeln!(s, ": {error}");
693                } else {
694                    let _ = writeln!(s);
695                }
696            }
697        }
698        let _ = writeln!(s);
699    }
700
701    fn write_outcome_to_string(&self, s: &mut String) {
702        use std::fmt::Write;
703        let _ = writeln!(
704            s,
705            "------------------------------------------------------------------------"
706        );
707        let _ = writeln!(s, "OUTCOME");
708        let _ = writeln!(
709            s,
710            "------------------------------------------------------------------------"
711        );
712        let _ = writeln!(s);
713        match &self.outcome {
714            Some(outcome) => {
715                let _ = writeln!(s, "{outcome}");
716            }
717            None => {
718                let _ = writeln!(s, "[Outcome not recorded]");
719            }
720        }
721        let _ = writeln!(s);
722        let _ = writeln!(
723            s,
724            "========================================================================"
725        );
726    }
727}
728
729/// Sanitize agent name for use in filename.
730fn sanitize_agent_name(agent: &str) -> String {
731    agent
732        .chars()
733        .map(|c| if c.is_alphanumeric() { c } else { '_' })
734        .collect::<String>()
735        .chars()
736        .take(20)
737        .collect()
738}
739
740/// Session tracker for commit generation logging.
741///
742/// Manages a unique run directory for a commit generation session,
743/// ensuring log files are organized and don't overwrite each other.
744#[derive(Debug)]
745pub struct CommitLogSession {
746    /// Base log directory
747    run_dir: PathBuf,
748    /// Current attempt counter
749    attempt_counter: usize,
750}
751
752impl CommitLogSession {
753    /// Create a new logging session using workspace abstraction.
754    ///
755    /// Creates a unique run directory under the base log path.
756    ///
757    /// # Arguments
758    ///
759    /// * `base_log_dir` - Base directory for commit logs (e.g., `.agent/logs/commit_generation`)
760    /// * `workspace` - The workspace to use for filesystem operations
761    pub fn new(base_log_dir: &str, workspace: &dyn Workspace) -> std::io::Result<Self> {
762        let timestamp = Local::now().format("%Y%m%d_%H%M%S");
763        let run_dir = PathBuf::from(base_log_dir).join(format!("run_{timestamp}"));
764        workspace.create_dir_all(&run_dir)?;
765
766        Ok(Self {
767            run_dir,
768            attempt_counter: 0,
769        })
770    }
771
772    /// Get the path to the run directory.
773    pub fn run_dir(&self) -> &Path {
774        &self.run_dir
775    }
776
777    /// Get the next attempt number and increment the counter.
778    pub const fn next_attempt_number(&mut self) -> usize {
779        self.attempt_counter += 1;
780        self.attempt_counter
781    }
782
783    /// Create a new attempt log for this session.
784    ///
785    /// # Arguments
786    ///
787    /// * `agent` - The agent being used
788    /// * `strategy` - The retry strategy being used
789    pub fn new_attempt(&mut self, agent: &str, strategy: &str) -> CommitAttemptLog {
790        let attempt_number = self.next_attempt_number();
791        CommitAttemptLog::new(attempt_number, agent, strategy)
792    }
793
794    /// Write summary file at end of session.
795    ///
796    /// # Arguments
797    ///
798    /// * `total_attempts` - Total number of attempts made
799    /// * `final_outcome` - Description of the final outcome
800    /// * `workspace` - The workspace to use for filesystem operations
801    pub fn write_summary(
802        &self,
803        total_attempts: usize,
804        final_outcome: &str,
805        workspace: &dyn Workspace,
806    ) -> std::io::Result<()> {
807        use std::fmt::Write;
808
809        let summary_path = self.run_dir.join("SUMMARY.txt");
810        let mut content = String::new();
811
812        let _ = writeln!(content, "COMMIT GENERATION SESSION SUMMARY");
813        let _ = writeln!(content, "=================================");
814        let _ = writeln!(content);
815        let _ = writeln!(content, "Run directory: {}", self.run_dir.display());
816        let _ = writeln!(content, "Total attempts: {total_attempts}");
817        let _ = writeln!(content, "Final outcome: {final_outcome}");
818        let _ = writeln!(content);
819        let _ = writeln!(content, "Individual attempt logs are in this directory.");
820
821        workspace.write(&summary_path, &content)?;
822        Ok(())
823    }
824}
825
826#[cfg(test)]
827mod tests {
828    use super::*;
829    use crate::workspace::MemoryWorkspace;
830
831    // =========================================================================
832    // Tests using MemoryWorkspace (architecture-conformant)
833    // =========================================================================
834
835    #[test]
836    fn test_attempt_log_write_to_workspace() {
837        let workspace = MemoryWorkspace::new_test();
838        let log_dir = Path::new(".agent/logs/commit_generation/run_test");
839
840        let mut log = CommitAttemptLog::new(1, "claude", "initial");
841        log.set_prompt_size(5000);
842        log.set_diff_info(10000, false);
843        log.set_raw_output("raw agent output here");
844        log.add_extraction_attempt(ExtractionAttempt::failure(
845            "XML",
846            "No <ralph-commit> tag found".to_string(),
847        ));
848        log.set_outcome(AttemptOutcome::Success("feat: add feature".to_string()));
849
850        let log_path = log.write_to_workspace(log_dir, &workspace).unwrap();
851        assert!(workspace.exists(&log_path));
852
853        let content = workspace.read(&log_path).unwrap();
854        assert!(content.contains("COMMIT GENERATION ATTEMPT LOG"));
855        assert!(content.contains("Attempt:   #1"));
856        assert!(content.contains("claude"));
857    }
858
859    #[test]
860    fn test_attempt_log_write_with_all_fields() {
861        let workspace = MemoryWorkspace::new_test();
862        let log_dir = Path::new(".agent/logs/commit_generation/run_test");
863
864        let mut log = CommitAttemptLog::new(1, "claude", "initial");
865        log.set_prompt_size(5000);
866        log.set_diff_info(10000, false);
867        log.set_raw_output("raw agent output here");
868        log.add_extraction_attempt(ExtractionAttempt::failure(
869            "XML",
870            "No <ralph-commit> tag found".to_string(),
871        ));
872        log.add_extraction_attempt(ExtractionAttempt::success(
873            "JSON",
874            "Extracted from JSON".to_string(),
875        ));
876        log.set_validation_checks(vec![
877            ValidationCheck::pass("basic_length"),
878            ValidationCheck::fail("no_bad_patterns", "File list pattern detected".to_string()),
879        ]);
880        log.set_outcome(AttemptOutcome::ExtractionFailed("bad pattern".to_string()));
881
882        let log_path = log.write_to_workspace(log_dir, &workspace).unwrap();
883        assert!(workspace.exists(&log_path));
884
885        let content = workspace.read(&log_path).unwrap();
886        assert!(content.contains("COMMIT GENERATION ATTEMPT LOG"));
887        assert!(content.contains("Attempt:   #1"));
888        assert!(content.contains("claude"));
889        assert!(content.contains("EXTRACTION ATTEMPTS"));
890        assert!(content.contains("VALIDATION RESULTS"));
891        assert!(content.contains("OUTCOME"));
892    }
893
894    #[test]
895    fn test_parsing_trace_write_to_workspace() {
896        let workspace = MemoryWorkspace::new_test();
897        let log_dir = Path::new(".agent/logs/commit_generation/run_test");
898
899        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
900        trace.set_raw_output("raw agent output");
901        trace.add_step(
902            ParsingTraceStep::new(1, "XML extraction")
903                .with_input("input")
904                .with_success(true),
905        );
906        trace.set_final_message("feat: add feature");
907
908        let trace_path = trace.write_to_workspace(log_dir, &workspace).unwrap();
909        assert!(workspace.exists(&trace_path));
910
911        let content = workspace.read(&trace_path).unwrap();
912        assert!(content.contains("PARSING TRACE LOG"));
913        assert!(content.contains("Attempt #001"));
914    }
915
916    #[test]
917    fn test_parsing_trace_write_with_steps() {
918        let workspace = MemoryWorkspace::new_test();
919        let log_dir = Path::new(".agent/logs/commit_generation/run_test");
920
921        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
922        trace.set_raw_output("raw agent output");
923        trace.add_step(
924            ParsingTraceStep::new(1, "XML extraction")
925                .with_input("input")
926                .with_result("result")
927                .with_success(true)
928                .with_details("success"),
929        );
930        trace.add_step(
931            ParsingTraceStep::new(2, "Validation")
932                .with_success(false)
933                .with_details("failed"),
934        );
935        trace.set_final_message("feat: add feature");
936
937        let trace_path = trace.write_to_workspace(log_dir, &workspace).unwrap();
938        assert!(workspace.exists(&trace_path));
939        assert!(trace_path.to_string_lossy().contains("parsing_trace"));
940
941        let content = workspace.read(&trace_path).unwrap();
942        assert!(content.contains("PARSING TRACE LOG"));
943        assert!(content.contains("Attempt #001"));
944        assert!(content.contains("RAW AGENT OUTPUT"));
945        assert!(content.contains("PARSING STEPS"));
946        assert!(content.contains("FINAL EXTRACTED MESSAGE"));
947    }
948
949    #[test]
950    fn test_session_creates_run_directory() {
951        let workspace = MemoryWorkspace::new_test();
952
953        let session = CommitLogSession::new(".agent/logs/commit_generation", &workspace).unwrap();
954        assert!(workspace.exists(session.run_dir()));
955        assert!(session.run_dir().to_string_lossy().contains("run_"));
956    }
957
958    #[test]
959    fn test_session_increments_attempt_number() {
960        let workspace = MemoryWorkspace::new_test();
961
962        let mut session =
963            CommitLogSession::new(".agent/logs/commit_generation", &workspace).unwrap();
964
965        assert_eq!(session.next_attempt_number(), 1);
966        assert_eq!(session.next_attempt_number(), 2);
967        assert_eq!(session.next_attempt_number(), 3);
968    }
969
970    #[test]
971    fn test_session_new_attempt() {
972        let workspace = MemoryWorkspace::new_test();
973
974        let mut session =
975            CommitLogSession::new(".agent/logs/commit_generation", &workspace).unwrap();
976
977        let log1 = session.new_attempt("claude", "initial");
978        assert_eq!(log1.attempt_number, 1);
979
980        let log2 = session.new_attempt("glm", "strict_json");
981        assert_eq!(log2.attempt_number, 2);
982    }
983
984    #[test]
985    fn test_session_write_summary() {
986        let workspace = MemoryWorkspace::new_test();
987
988        let session = CommitLogSession::new(".agent/logs/commit_generation", &workspace).unwrap();
989        session
990            .write_summary(5, "SUCCESS: feat: add feature", &workspace)
991            .unwrap();
992
993        let summary_path = session.run_dir().join("SUMMARY.txt");
994        assert!(workspace.exists(&summary_path));
995
996        let content = workspace.read(&summary_path).unwrap();
997        assert!(content.contains("Total attempts: 5"));
998        assert!(content.contains("SUCCESS"));
999    }
1000
1001    #[test]
1002    fn test_sanitize_agent_name() {
1003        assert_eq!(sanitize_agent_name("claude"), "claude");
1004        assert_eq!(sanitize_agent_name("agent/commit"), "agent_commit");
1005        assert_eq!(sanitize_agent_name("my-agent-v2"), "my_agent_v2");
1006        // Long names are truncated
1007        let long_name = "a".repeat(50);
1008        assert_eq!(sanitize_agent_name(&long_name).len(), 20);
1009    }
1010
1011    #[test]
1012    fn test_large_output_truncation() {
1013        let mut log = CommitAttemptLog::new(1, "test", "test");
1014        let large_output = "x".repeat(100_000);
1015        log.set_raw_output(&large_output);
1016
1017        let output = log.raw_output.unwrap();
1018        assert!(output.len() < large_output.len());
1019        assert!(output.contains("[... truncated"));
1020    }
1021
1022    #[test]
1023    fn test_parsing_trace_step_creation() {
1024        let step = ParsingTraceStep::new(1, "XML extraction");
1025        assert_eq!(step.step_number, 1);
1026        assert_eq!(step.description, "XML extraction");
1027        assert!(!step.success);
1028        assert!(step.input.is_none());
1029        assert!(step.result.is_none());
1030    }
1031
1032    #[test]
1033    fn test_parsing_trace_step_builder() {
1034        let step = ParsingTraceStep::new(1, "XML extraction")
1035            .with_input("input content")
1036            .with_result("result content")
1037            .with_success(true)
1038            .with_details("extraction successful");
1039
1040        assert!(step.success);
1041        assert_eq!(step.input.as_deref(), Some("input content"));
1042        assert_eq!(step.result.as_deref(), Some("result content"));
1043        assert_eq!(step.details, "extraction successful");
1044    }
1045
1046    #[test]
1047    fn test_parsing_trace_step_truncation() {
1048        let large_input = "x".repeat(100_000);
1049        let step = ParsingTraceStep::new(1, "test").with_input(&large_input);
1050
1051        assert!(step.input.is_some());
1052        let input = step.input.as_ref().unwrap();
1053        assert!(input.len() < large_input.len());
1054        assert!(input.contains("[... input truncated"));
1055    }
1056
1057    #[test]
1058    fn test_parsing_trace_log_creation() {
1059        let trace = ParsingTraceLog::new(1, "claude", "initial");
1060        assert_eq!(trace.attempt_number, 1);
1061        assert_eq!(trace.agent, "claude");
1062        assert_eq!(trace.strategy, "initial");
1063        assert!(trace.raw_output.is_none());
1064        assert!(trace.steps.is_empty());
1065        assert!(trace.final_message.is_none());
1066    }
1067
1068    #[test]
1069    fn test_parsing_trace_log_set_raw_output() {
1070        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
1071        trace.set_raw_output("test output");
1072
1073        assert_eq!(trace.raw_output.as_deref(), Some("test output"));
1074    }
1075
1076    #[test]
1077    fn test_parsing_trace_raw_output_truncation() {
1078        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
1079        let large_output = "x".repeat(100_000);
1080        trace.set_raw_output(&large_output);
1081
1082        let output = trace.raw_output.unwrap();
1083        assert!(output.len() < large_output.len());
1084        assert!(output.contains("[... raw output truncated"));
1085    }
1086
1087    #[test]
1088    fn test_parsing_trace_add_step() {
1089        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
1090        let step = ParsingTraceStep::new(1, "XML extraction");
1091        trace.add_step(step);
1092
1093        assert_eq!(trace.steps.len(), 1);
1094        assert_eq!(trace.steps[0].description, "XML extraction");
1095    }
1096
1097    #[test]
1098    fn test_parsing_trace_set_final_message() {
1099        let mut trace = ParsingTraceLog::new(1, "claude", "initial");
1100        trace.set_final_message("feat: add feature");
1101
1102        assert_eq!(trace.final_message.as_deref(), Some("feat: add feature"));
1103    }
1104
1105    #[test]
1106    fn test_attempt_log_creation() {
1107        let log = CommitAttemptLog::new(1, "claude", "initial");
1108        assert_eq!(log.attempt_number, 1);
1109        assert_eq!(log.agent, "claude");
1110        assert_eq!(log.strategy, "initial");
1111        assert!(log.raw_output.is_none());
1112        assert!(log.extraction_attempts.is_empty());
1113        assert!(log.validation_checks.is_empty());
1114        assert!(log.outcome.is_none());
1115    }
1116
1117    #[test]
1118    fn test_attempt_log_set_values() {
1119        let mut log = CommitAttemptLog::new(2, "glm", "strict_json");
1120
1121        log.set_prompt_size(10_000);
1122        log.set_diff_info(50_000, true);
1123        log.set_raw_output("test output");
1124
1125        assert_eq!(log.prompt_size_bytes, 10_000);
1126        assert_eq!(log.diff_size_bytes, 50_000);
1127        assert!(log.diff_was_truncated);
1128        assert_eq!(log.raw_output.as_deref(), Some("test output"));
1129    }
1130
1131    #[test]
1132    fn test_extraction_attempt_creation() {
1133        let success =
1134            ExtractionAttempt::success("XML", "Found <ralph-commit> at pos 0".to_string());
1135        assert!(success.success);
1136        assert_eq!(success.method, "XML");
1137
1138        let failure = ExtractionAttempt::failure("JSON", "No JSON found".to_string());
1139        assert!(!failure.success);
1140        assert_eq!(failure.method, "JSON");
1141    }
1142
1143    #[test]
1144    fn test_validation_check_creation() {
1145        let pass = ValidationCheck::pass("basic_length");
1146        assert!(pass.passed);
1147        assert!(pass.error.is_none());
1148
1149        let fail = ValidationCheck::fail("no_json_artifacts", "Found JSON in message".to_string());
1150        assert!(!fail.passed);
1151        assert!(fail.error.is_some());
1152    }
1153
1154    #[test]
1155    fn test_outcome_display() {
1156        let success = AttemptOutcome::Success("feat: add feature".to_string());
1157        assert!(format!("{success}").contains("SUCCESS"));
1158
1159        let error = AttemptOutcome::ExtractionFailed("extraction failed".to_string());
1160        assert!(format!("{error}").contains("EXTRACTION_FAILED"));
1161    }
1162}