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