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